All Taste components
Forms
Select & combobox
A native select for short lists, and a searchable combobox for long ones.
native select · combobox open · match highlight · selected check
- BengaluruKarnataka
- BelagaviKarnataka
- BerhampurOdisha
"use client";
import { useId, useState } from "react";
import { Check, ChevronDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
const field =
"rounded border border-border-strong bg-bg transition focus-within:border-primary focus-within:ring-[3px] focus-within:ring-primary/20";
export function Select({ label, options, ...props }:
React.SelectHTMLAttributes<HTMLSelectElement> & { label: string; options: { value: string; label: string }[] }) {
const id = useId();
return (
<div className="grid gap-1.5">
<label htmlFor={id} className="text-[13px] font-medium text-fg">{label}</label>
<div className={cn("relative", field)}>
<select id={id} className="h-9 w-full appearance-none bg-transparent pl-3 pr-9 text-sm text-fg outline-none" {...props}>
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" aria-hidden />
</div>
</div>
);
}
type Option = { value: string; label: string; hint?: string };
export function Combobox({ label, options, value, onChange, placeholder = "Search…" }:
{ label: string; options: Option[]; value: string | null; onChange: (value: string) => void; placeholder?: string }) {
const id = useId();
const [query, setQuery] = useState(options.find((o) => o.value === value)?.label ?? "");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const matches = options.filter((o) => o.label.toLowerCase().includes(query.trim().toLowerCase()));
function pick(o: Option) {
onChange(o.value);
setQuery(o.label);
setOpen(false);
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); setActive((i) => Math.min(i + 1, matches.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); }
else if (e.key === "Enter" && open && matches[active]) { e.preventDefault(); pick(matches[active]); }
else if (e.key === "Escape") setOpen(false);
}
return (
<div className="relative grid gap-1.5">
<label htmlFor={id} className="text-[13px] font-medium text-fg">{label}</label>
<div className={cn("flex h-9 items-center gap-2 px-2.5", field)}>
<Search className="size-4 shrink-0 text-fg-subtle" aria-hidden />
<input
id={id}
role="combobox"
aria-expanded={open}
aria-controls={id + "-list"}
aria-autocomplete="list"
aria-activedescendant={open && matches[active] ? id + "-" + matches[active].value : undefined}
value={query}
placeholder={placeholder}
onChange={(e) => { setQuery(e.target.value); setOpen(true); setActive(0); }}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={onKeyDown}
className="h-full min-w-0 flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-subtle"
/>
<ChevronDown className="size-4 shrink-0 text-fg-subtle" aria-hidden />
</div>
{open && (
<ul id={id + "-list"} role="listbox" aria-label={label}
className="absolute inset-x-0 top-full z-10 mt-1 max-h-60 overflow-auto rounded-lg border border-border bg-bg p-1 shadow-lg">
{matches.length === 0 && <li className="px-2 py-2 text-[13px] text-fg-subtle">No matches</li>}
{matches.map((o, i) => (
<li
key={o.value}
id={id + "-" + o.value}
role="option"
aria-selected={o.value === value}
onMouseDown={(e) => { e.preventDefault(); pick(o); }}
onMouseEnter={() => setActive(i)}
className={cn(
"flex min-h-[34px] cursor-pointer items-center gap-2 rounded px-2 text-sm text-fg",
i === active && "bg-surface-2",
o.value === value && "font-medium",
)}
>
<span className="truncate">{o.label}</span>
{o.hint && <span className="text-xs font-normal text-fg-subtle">{o.hint}</span>}
<Check className={cn("ml-auto size-4 shrink-0 text-primary", o.value !== value && "invisible")} aria-hidden />
</li>
))}
</ul>
)}
</div>
);
}
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.