Skip to content
115 linesCodeBlameRaw
1import { Check, ChevronsUpDown } from "lucide-react";
2import { type ReactNode, useState } from "react";
3
4import { cn } from "../../lib/cn";
5import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "./command";
6import { Popover, PopoverContent, PopoverTrigger } from "./popover";
7
8// shadcn/ui's combobox, styled with g1t's tokens: a select with a search
9// box, for lists too long to scroll. A `name` posts the chosen value from a
10// hidden input, so it works in a plain <Form>.
11
12export type ComboboxOption = {
13 value: string;
14 label: string;
15 description?: ReactNode;
16 icon?: ReactNode;
17 /** More words that should find it. */
18 keywords?: string[];
19 disabled?: boolean;
20};
21
22/** A searchable select. Controlled with `value`, or not with `defaultValue`. */
23export function Combobox({
24 options,
25 value: controlled,
26 defaultValue = "",
27 onValueChange,
28 name,
29 disabled,
30 placeholder = "Choose…",
31 searchPlaceholder = "Search…",
32 emptyText = "Nothing matches.",
33 id,
34 className,
35 "aria-label": ariaLabel,
36}: {
37 options: ComboboxOption[];
38 value?: string;
39 defaultValue?: string;
40 onValueChange?: (value: string) => void;
41 name?: string;
42 disabled?: boolean;
43 placeholder?: string;
44 searchPlaceholder?: string;
45 emptyText?: string;
46 id?: string;
47 className?: string;
48 "aria-label"?: string;
49}) {
50 const [open, setOpen] = useState(false);
51 const [uncontrolled, setUncontrolled] = useState(defaultValue);
52 const value = controlled ?? uncontrolled;
53 const chosen = options.find((option) => option.value === value);
54 const choose = (next: string) => {
55 setUncontrolled(next);
56 onValueChange?.(next);
57 setOpen(false);
58 };
59 return (
60 <Popover open={open} onOpenChange={setOpen}>
61 {name && <input type="hidden" name={name} value={value} />}
62 <PopoverTrigger asChild>
63 <button
64 type="button"
65 id={id}
66 role="combobox"
67 aria-expanded={open}
68 aria-label={ariaLabel}
69 disabled={disabled}
70 className={cn(
71 "group/combobox flex h-9 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-line bg-bg px-3 text-left text-sm text-fg outline-none transition-colors",
72 "hover:border-line-strong focus-visible:border-accent-dim focus-visible:ring-2 focus-visible:ring-accent/25 focus-visible:outline-none",
73 "data-[state=open]:border-accent-dim data-[state=open]:ring-2 data-[state=open]:ring-accent/25 disabled:cursor-not-allowed disabled:opacity-50",
74 className,
75 )}
76 >
77 <span className={cn("flex min-w-0 items-center gap-2 truncate [&_svg]:size-4 [&_svg]:shrink-0", !chosen && "text-faint")}>
78 {chosen?.icon}
79 <span className="truncate">{chosen ? chosen.label : placeholder}</span>
80 </span>
81 <ChevronsUpDown size={14} className="shrink-0 text-faint" />
82 </button>
83 </PopoverTrigger>
84 <PopoverContent align="start" className="w-(--radix-popover-trigger-width) min-w-60 p-0">
85 <Command>
86 <CommandInput placeholder={searchPlaceholder} />
87 <CommandList>
88 <CommandEmpty>{emptyText}</CommandEmpty>
89 {options.map((option) => (
90 <CommandItem
91 key={option.value}
92 value={option.value}
93 keywords={[option.label, ...(option.keywords ?? [])]}
94 disabled={option.disabled}
95 onSelect={() => choose(option.value)}
96 className="items-start pr-8"
97 >
98 <span className="flex min-w-0 flex-col">
99 <span className="flex min-w-0 items-center gap-2">
100 {option.icon}
101 <span className="truncate">{option.label}</span>
102 </span>
103 {option.description && (
104 <span className={cn("mt-0.5 text-xs text-faint", option.icon != null && "pl-6")}>{option.description}</span>
105 )}
106 </span>
107 {option.value === value && <Check className="absolute top-2 right-2 size-3.5! text-accent!" />}
108 </CommandItem>
109 ))}
110 </CommandList>
111 </Command>
112 </PopoverContent>
113 </Popover>
114 );
115}