Combobox
A text field that filters a list as you type, with chips for choosing several.
Vibra draws the popup as the kit's elev-1 floating layer and fades it in over --duration-base without shadcn's zoom, sliding 4px from its side rather than 8; options are set in the 13px label register. The field itself is shadcn's input group. The popup reads out how many options the typing leaves ("12 results"; resultsLabel changes the words) and keeps its empty message in the page, so an empty list is heard as well; Base UI's icon buttons are named — the list button beside the field "Show options", the clear button "Clear", each chip's cross "Remove" and the chip's words — and the list button is out of the tab order, since the field opens the list from the keyboard. An option's padding and its check, a chip's padding and its cross, and the field's own buttons all sit on the inline axis, so on a right-to-left page the check lands at the end of an option's words and a chip's cross at the end of its name. The chips field stands on the card plane in both themes, as Input does.
Install
npx shadcn@latest add @vibra/comboboxNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"
const TIME_ZONES = [
"Europe/Dublin",
"Europe/London",
"Europe/Berlin",
"Europe/Warsaw",
"America/Toronto",
"America/Chicago",
"America/Denver",
"Asia/Singapore",
"Asia/Tokyo",
"Australia/Sydney",
]
export default function ComboboxDemo() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="workspace-time-zone">Workspace time zone</Label>
<Combobox items={TIME_ZONES} defaultValue="Europe/Dublin">
<ComboboxInput id="workspace-time-zone" placeholder="Search time zones" />
<ComboboxContent>
<ComboboxEmpty>No time zone by that name.</ComboboxEmpty>
<ComboboxList>
{(zone: string) => (
<ComboboxItem key={zone} value={zone}>
{zone}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}Grouped
Metrics under where they come from; typing filters every group at once, and an emptied group takes its heading with it.
"use client"
import * as React from "react"
import {
Combobox,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"
const METRICS = [
{
value: "Revenue",
items: ["Monthly recurring revenue", "Annual recurring revenue", "Net revenue retention", "Average revenue per account"],
},
{
value: "Customers",
items: ["Active customers", "New customers", "Churned customers", "Customer lifetime value"],
},
{
value: "Product",
items: ["Daily active users", "Sessions per user", "Feature adoption", "Time to first report"],
},
]
// The metric a chart plots, grouped by where it comes from. Typing filters
// inside every group at once, a group with nothing left steps out of the
// list, and its heading goes with it.
export default function ComboboxGroups() {
const id = React.useId()
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={id}>Chart metric</Label>
<Combobox items={METRICS} defaultValue="Net revenue retention">
<ComboboxInput id={id} placeholder="Search metrics" />
<ComboboxContent>
<ComboboxEmpty>No metric by that name.</ComboboxEmpty>
<ComboboxList>
{(group: (typeof METRICS)[number], index: number) => (
<React.Fragment key={group.value}>
{index > 0 ? <ComboboxSeparator /> : null}
<ComboboxGroup items={group.items}>
<ComboboxLabel>{group.value}</ComboboxLabel>
<ComboboxCollection>
{(metric: string) => (
<ComboboxItem key={metric} value={metric}>
{metric}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
</React.Fragment>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}Two lines, with a mark
Accounts drawn with their marks and a line that tells similar names apart; the field takes the name alone.
"use client"
import * as React from "react"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"
import { LogoMark, type LogoName } from "@/components/ui/logo-mark"
type Account = { value: LogoName; label: LogoName; city: string; seats: number; plan: string }
const ACCOUNTS: Account[] = [
{ value: "Keystone", label: "Keystone", city: "Rotterdam", seats: 1100, plan: "Enterprise" },
{ value: "Harbour", label: "Harbour", city: "Dublin", seats: 240, plan: "Team" },
{ value: "Lumen", label: "Lumen", city: "Osaka", seats: 92, plan: "Team" },
{ value: "Meridian", label: "Meridian", city: "Lyon", seats: 3400, plan: "Enterprise" },
{ value: "Kestrel", label: "Kestrel", city: "Oslo", seats: 610, plan: "Enterprise" },
{ value: "Alder", label: "Alder", city: "Austin", seats: 45, plan: "Starter" },
]
// Accounts drawn with their marks, and a second line that tells two
// similar names apart: where they are, how big, and on which plan. The
// field itself takes the name alone, which is what gets typed and filtered.
export default function ComboboxDetails() {
const id = React.useId()
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={id}>Account</Label>
<Combobox items={ACCOUNTS} defaultValue={ACCOUNTS[1]}>
<ComboboxInput id={id} placeholder="Search accounts" />
<ComboboxContent>
<ComboboxEmpty>No account by that name.</ComboboxEmpty>
<ComboboxList>
{(account: Account) => (
<ComboboxItem key={account.value} value={account} className="py-1.5">
<span className="flex min-w-0 flex-col gap-0.5">
<LogoMark name={account.value} size="sm" />{" "}
<span className="text-xs text-muted-foreground">
{account.city} · <span className="tabular-nums">{account.seats.toLocaleString("en-US")}</span> seats ·{" "}
{account.plan}
</span>
</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}Long list, counted
Forty-eight countries that scroll under the panel's edge, and a foot that says how many the typing has left.
"use client"
import * as React from "react"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"
const COUNTRIES = [
"Argentina", "Australia", "Austria", "Belgium", "Brazil", "Bulgaria", "Canada", "Chile", "Colombia",
"Croatia", "Cyprus", "Czechia", "Denmark", "Estonia", "Finland", "France", "Germany", "Greece",
"Hungary", "Iceland", "India", "Indonesia", "Ireland", "Israel", "Italy", "Japan", "Latvia",
"Lithuania", "Luxembourg", "Malaysia", "Malta", "Mexico", "Netherlands", "New Zealand", "Norway",
"Poland", "Portugal", "Romania", "Singapore", "Slovakia", "Slovenia", "South Africa", "South Korea",
"Spain", "Sweden", "Switzerland", "United Kingdom", "United States",
]
// Forty-eight countries: the list scrolls under the panel's edge, and the
// panel's foot says how many the typing has left. The combobox reads the same
// count out as it changes, so the foot is for the eye only.
export default function ComboboxLongList() {
const id = React.useId()
const [value, setValue] = React.useState<string | null>(null)
const [query, setQuery] = React.useState("")
// The field holding the chosen country shows the whole list again, as the
// combobox does, so the count follows it.
const needle = query === value ? "" : query.trim().toLowerCase()
const left = COUNTRIES.filter((country) => country.toLowerCase().includes(needle)).length
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={id}>Ship to</Label>
<Combobox items={COUNTRIES} value={value} onValueChange={setValue} onInputValueChange={setQuery}>
<ComboboxInput id={id} placeholder="Search 48 countries" />
<ComboboxContent>
<ComboboxEmpty>No country by that name.</ComboboxEmpty>
<ComboboxList>
{(country: string) => (
<ComboboxItem key={country} value={country}>
{country}
</ComboboxItem>
)}
</ComboboxList>
<p aria-hidden="true" className="border-t border-border px-2.5 py-1.5 text-xs text-muted-foreground tabular-nums">
{left === COUNTRIES.length ? `${left} countries` : `${left} of ${COUNTRIES.length} countries`}
</p>
</ComboboxContent>
</Combobox>
</div>
)
}Several, as chips
multiple with ComboboxChips: a chip per teammate, each cross named for its chip, and Backspace takes the last one off.
"use client"
import * as React from "react"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"
type Person = { value: string; label: string; email: string }
const PEOPLE: Person[] = [
{ value: "hiro", label: "Hiro Tanaka", email: "hiro@northwind.example" },
{ value: "saoirse", label: "Saoirse Byrne", email: "saoirse@northwind.example" },
{ value: "uma", label: "Uma Raghavan", email: "uma@northwind.example" },
{ value: "femi", label: "Femi Adebayo", email: "femi@northwind.example" },
{ value: "petra", label: "Petra Lindqvist", email: "petra@northwind.example" },
{ value: "lior", label: "Lior Keller", email: "lior@northwind.example" },
]
// Several people, each a chip in the field with a remove button named for
// them. Picking from the open list keeps it open for the next pick; picking
// what a search found closes it. Backspace in the empty field takes the last
// chip off, and the line under the field counts who can open the report.
export default function ComboboxMultiple() {
const id = React.useId()
const anchor = useComboboxAnchor()
const [people, setPeople] = React.useState<Person[]>([PEOPLE[0], PEOPLE[2]])
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor={id}>Share the Q3 revenue report with</Label>
<Combobox items={PEOPLE} multiple value={people} onValueChange={setPeople}>
<ComboboxChips ref={anchor}>
<ComboboxValue>
{(chosen: Person[]) =>
chosen.map((person) => <ComboboxChip key={person.value}>{person.label}</ComboboxChip>)
}
</ComboboxValue>
<ComboboxChipsInput id={id} placeholder={people.length > 0 ? undefined : "Add teammates"} />
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>Nobody by that name at Northwind.</ComboboxEmpty>
<ComboboxList>
{(person: Person) => (
<ComboboxItem key={person.value} value={person}>
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(person.label)} alt="" />
<AvatarFallback>{getInitials(person.label)}</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-col">
<span>{person.label}</span>{" "}
<span className="text-xs text-muted-foreground">{person.email}</span>
</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<p className="text-xs text-muted-foreground">
{people.length === 0
? "Only you can open the report."
: `You and ${people.length} ${people.length === 1 ? "teammate" : "teammates"} can open the report.`}
</p>
</div>
)
}A button with the search inside
The trigger shows the choice and is the combobox; the field inside the panel only filters.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
ComboboxValue,
} from "@/components/ui/combobox"
const PROJECTS = [
"Q3 close",
"Churn analysis",
"Customer portal",
"Data warehouse migration",
"Pricing experiment",
"SOC 2 audit",
"Website relaunch",
]
// A button that shows the choice, with the search inside the list it opens:
// the trigger is the combobox, named by its label and read with its value,
// and the field inside the panel only filters.
export default function ComboboxButton() {
const id = React.useId()
const [project, setProject] = React.useState<string | null>("Q3 close")
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<span id={id} className="text-sm font-medium">
Move 3 invoices to
</span>
<Combobox items={PROJECTS} value={project} onValueChange={setProject}>
<ComboboxTrigger
aria-labelledby={id}
render={<Button variant="outline" className="w-full justify-between font-normal" />}
>
<ComboboxValue placeholder="Choose a project" />
</ComboboxTrigger>
<ComboboxContent>
<ComboboxInput showTrigger={false} placeholder="Search projects" aria-label="Search projects" />
<ComboboxEmpty>No project by that name.</ComboboxEmpty>
<ComboboxList>
{(name: string) => (
<ComboboxItem key={name} value={name}>
{name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}Searching the server
filter={null} with the answers as items: the newest question wins, and the panel says it is searching where the rows will be.
"use client"
import * as React from "react"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
type Customer = { value: string; label: string; detail: string }
const DIRECTORY: Customer[] = [
{ value: "cus_0142", label: "Blue Harbor Logistics", detail: "Rotterdam · Enterprise" },
{ value: "cus_0217", label: "Beacon Freight", detail: "Hamburg · Team" },
{ value: "cus_0305", label: "Meridian Foods", detail: "Lyon · Enterprise" },
{ value: "cus_0388", label: "Nordic Supply", detail: "Oslo · Team" },
{ value: "cus_0419", label: "Volta Labs", detail: "Austin · Starter" },
{ value: "cus_0450", label: "Blue Ridge Analytics", detail: "Denver · Team" },
{ value: "cus_0523", label: "Harbor Point Retail", detail: "Boston · Starter" },
]
/** Stands in for the search endpoint: a round trip, then the matches. */
function search(query: string): Promise<Customer[]> {
const needle = query.trim().toLowerCase()
return new Promise((resolve) =>
setTimeout(() => resolve(DIRECTORY.filter((customer) => customer.label.toLowerCase().includes(needle))), 400)
)
}
// The list comes from the server, so the combobox filters nothing itself
// (filter={null}). Each change asks again and the newest question wins; while
// it waits the panel says so where the rows will be, in the region that
// announces an empty list, and the combobox reads the count out when they land.
export default function ComboboxAsync() {
const id = React.useId()
const latest = React.useRef("")
const [query, setQuery] = React.useState("")
const [results, setResults] = React.useState<Customer[]>([])
const [pending, setPending] = React.useState(false)
function ask(next: string) {
latest.current = next
setQuery(next)
setResults([])
if (next.trim().length < 2) {
setPending(false)
return
}
setPending(true)
void search(next).then((found) => {
if (latest.current !== next) return
setResults(found)
setPending(false)
})
}
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={id}>Customer</Label>
<Combobox items={results} filter={null} onInputValueChange={ask}>
<ComboboxInput id={id} placeholder="Type two letters to search" />
<ComboboxContent>
<ComboboxEmpty className="gap-2">
{pending ? (
<>
<Spinner aria-hidden="true" />
Searching customers…
</>
) : query.trim().length < 2 ? (
"Type two letters to search."
) : (
"No customer matches."
)}
</ComboboxEmpty>
<ComboboxList>
{(customer: Customer) => (
<ComboboxItem key={customer.value} value={customer}>
<span className="flex min-w-0 flex-col">
<span>{customer.label}</span>{" "}
<span className="text-xs text-muted-foreground">{customer.detail}</span>
</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}Create what isn't there
Typing a label the list lacks adds a last option that creates it and keeps it for next time.
"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import {
Combobox,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"
type Tag = { value: string; label: string; create?: true }
const START: Tag[] = [
{ value: "billing", label: "Billing" },
{ value: "bug", label: "Bug" },
{ value: "feature-request", label: "Feature request" },
{ value: "onboarding", label: "Onboarding" },
{ value: "sso", label: "SSO" },
]
const slug = (label: string) => label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
// A label the list doesn't have yet is one Enter away: typing something new
// adds a last option that creates it, and choosing that option adds the label
// to the list for next time and puts it on the ticket.
export default function ComboboxCreate() {
const id = React.useId()
const [tags, setTags] = React.useState(START)
const [value, setValue] = React.useState<Tag | null>(START[1])
const [query, setQuery] = React.useState("")
const typed = query.trim()
const known = tags.some((tag) => tag.label.toLowerCase() === typed.toLowerCase())
const items: Tag[] =
typed && !known && typed !== value?.label ? [...tags, { value: `create:${slug(typed)}`, label: typed, create: true }] : tags
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={id}>Label for ticket #4821</Label>
<Combobox
items={items}
value={value}
onInputValueChange={setQuery}
onValueChange={(next) => {
if (next?.create) {
const made = { value: slug(next.label), label: next.label }
setTags((current) => [...current, made])
setValue(made)
} else {
setValue(next)
}
}}
>
<ComboboxInput id={id} placeholder="Search or create a label" />
<ComboboxContent>
<ComboboxList>
{(tag: Tag) => (
<ComboboxItem key={tag.value} value={tag}>
{tag.create ? (
<>
<PlusIcon aria-hidden="true" className="text-muted-foreground" />
<span>
Create <span className="font-medium">“{tag.label}”</span>
</span>
</>
) : (
tag.label
)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<p className="text-xs text-muted-foreground">
<span className="tabular-nums">{tags.length}</span> labels in this workspace.
</p>
</div>
)
}Required, with an error
Typing is not choosing: sending with nothing chosen marks the field, points it at the message and hands the focus back.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
const OWNERS = ["Femi Adebayo", "Hiro Tanaka", "Lior Keller", "Petra Lindqvist", "Saoirse Byrne", "Uma Raghavan"]
// A required combobox with a clear button. Typing a name is not choosing
// one: sending with nothing chosen marks the field invalid, points it at the
// message and moves the focus back to it. Clearing hands the focus back too.
export default function ComboboxInvalid() {
const id = React.useId()
const input = React.useRef<HTMLInputElement>(null)
const [owner, setOwner] = React.useState<string | null>(null)
const [tried, setTried] = React.useState(false)
const invalid = tried && owner === null
return (
<form
noValidate
className="flex w-full max-w-xs flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
setTried(true)
if (owner === null) input.current?.focus()
}}
>
<Field data-invalid={invalid || undefined}>
<FieldLabel htmlFor={id}>Deal owner</FieldLabel>
<Combobox items={OWNERS} value={owner} onValueChange={setOwner}>
<ComboboxInput
ref={input}
id={id}
placeholder="Search teammates"
showClear
aria-required
aria-invalid={invalid || undefined}
aria-describedby={invalid ? `${id}-error ${id}-hint` : `${id}-hint`}
/>
<ComboboxContent>
<ComboboxEmpty>Nobody by that name at Northwind.</ComboboxEmpty>
<ComboboxList>
{(name: string) => (
<ComboboxItem key={name} value={name}>
{name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
{invalid ? (
<FieldError id={`${id}-error`} role={undefined}>
Choose who owns the Keystone renewal.
</FieldError>
) : null}
<FieldDescription id={`${id}-hint`}>The owner gets the reminders and the forecast.</FieldDescription>
</Field>
<div className="flex items-center justify-between gap-3">
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{tried && owner !== null ? `Deal saved with ${owner} as owner.` : null}
</p>
<Button type="submit" size="sm">
Save deal
</Button>
</div>
</form>
)
}Unavailable options, with the reason
Agents who can't take the ticket stay in the list with the reason in their row; the arrow keys reach them and Enter passes them by.
"use client"
import * as React from "react"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"
type Agent = { value: string; label: string; open: number; away?: string }
const AGENTS: Agent[] = [
{ value: "hiro", label: "Hiro Tanaka", open: 4 },
{ value: "petra", label: "Petra Lindqvist", open: 0, away: "Away until 8 September" },
{ value: "uma", label: "Uma Raghavan", open: 7 },
{ value: "lior", label: "Lior Keller", open: 12, away: "At the limit of 12 open tickets" },
{ value: "femi", label: "Femi Adebayo", open: 2 },
]
// Agents who can't take a ticket stay in the list, dimmed, with the reason in
// place of their load — the arrow keys still land on them, so the reason is
// heard as well as seen, and Enter passes them by.
export default function ComboboxUnavailable() {
const id = React.useId()
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={id}>Assign ticket #4821 to</Label>
<Combobox items={AGENTS} defaultValue={AGENTS[0]}>
<ComboboxInput id={id} placeholder="Search agents" />
<ComboboxContent>
<ComboboxEmpty>No agent by that name.</ComboboxEmpty>
<ComboboxList>
{(agent: Agent) => (
<ComboboxItem
key={agent.value}
value={agent}
disabled={Boolean(agent.away)}
// The name dims; the reason keeps its strength, since it is
// what the reader stops on the row to learn.
className="justify-between data-disabled:opacity-100"
>
<span className={agent.away ? "opacity-50" : undefined}>{agent.label}</span>{" "}
<span className="text-xs text-muted-foreground tabular-nums">
{agent.away ?? `${agent.open} open`}
</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | T[] | { value: string; items: T[] }[] | — | The options, flat or in groups; the root filters them as the reader types and the popup counts what is left. |
| multiple | boolean | false | Several choices, one ComboboxChip each inside ComboboxChips; the value is an array. |
| filter | null | ((item, query) => boolean) | — | null when the server does the filtering: pass its answers as items. |
| ComboboxInput.showTrigger | boolean | true | Draws the chevron button that opens the list: named, and out of the tab order. |
| ComboboxInput.showClear | boolean | false | Draws a clear button once something is chosen. |
| ComboboxChip.showRemove | boolean | true | Gives each chip of a multiple combobox its own remove button, named for the chip. |
| ComboboxContent.resultsLabel | (count: number) => string | "12 results" | The words read out for how many options are left; silent when none are, which ComboboxEmpty says. |
| ComboboxItem.disabled | boolean | false | Dims the option and refuses it; the arrow keys still reach it, so a reason in its words is heard. |
Dependencies
Source
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
// Base UI's clear button has no name of its own; a caller's wins.
aria-label="Clear"
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
// The group draws the field's focus. The input inside it would draw
// Input's own outline as well — a second, square box cut into the
// group's border — so it keeps its outline to itself.
render={<InputGroupInput disabled={disabled} className="focus-visible:outline-none" />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
// The field is the one tab stop and opens the list from the
// keyboard; the button is the pointer's way in, as in the ARIA
// combobox pattern, and is named for a screen reader's browse mode.
aria-label="Show options"
tabIndex={-1}
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
/** "1 result", "12 results": what the popup reads out as the typing narrows the list. */
function countResults(count: number) {
return `${count} ${count === 1 ? "result" : "results"}`
}
/** A group from `items` holds its options in `items`; everything else is an option. */
function isGroup(entry: unknown): entry is { items: readonly unknown[] } {
return typeof entry === "object" && entry !== null && Array.isArray((entry as { items?: unknown }).items)
}
/**
* Reads out how many options the typing has left, from the list the root
* filtered — so it speaks only for a combobox given `items`. An empty list is
* ComboboxEmpty's to announce, so this falls silent then.
*/
function ComboboxResultsStatus({ label }: { label: (count: number) => string }) {
const filtered = ComboboxPrimitive.useFilteredItems() as readonly unknown[]
const count = filtered.reduce<number>((total, entry) => total + (isGroup(entry) ? entry.items.length : 1), 0)
return (
<ComboboxPrimitive.Status data-slot="combobox-status" className="sr-only">
{count > 0 ? label(count) : null}
</ComboboxPrimitive.Status>
)
}
function ComboboxContent({
className,
children,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
resultsLabel = countResults,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
> & {
/** The words read out for how many options are left; "12 results" by default. */
resultsLabel?: (count: number) => string
}) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden elev-1 text-popover-foreground data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-1 data-[side=inline-end]:slide-in-from-left-1 data-[side=inline-start]:slide-in-from-right-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:duration-(--duration-base) data-open:ease-(--ease-standard) data-closed:animate-out data-closed:fade-out-0 data-closed:duration-(--duration-fast) data-closed:ease-(--ease-exit)", className )}
{...props}
>
{children}
<ComboboxResultsStatus label={resultsLabel} />
</ComboboxPrimitive.Popup>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 ps-1.5 pe-8 text-label outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute end-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
// Never display:none: Base UI's empty message is a live region, heard
// only if it is already in the page when its words arrive.
"w-full justify-center text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex group-data-empty/combobox-content:py-2",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-card bg-clip-padding px-2.5 py-1 text-sm transition-colors has-[input:focus-visible]:focus-outline has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
const labelId = React.useId()
const removeId = React.useId()
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pe-0",
className
)}
{...props}
>
<span id={labelId} data-slot="combobox-chip-label">
{children}
</span>
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ms-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
// "Remove Hiro Tanaka": the button's own word, then the chip's.
id={removeId}
aria-label="Remove"
aria-labelledby={`${removeId} ${labelId}`}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}