flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/components/ui/combobox.tsx

169 lines6,549 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Workspace names and icons, and a component kit for every control1import { Command as Cmdk } from "cmdk";
2import { Check, ChevronsUpDown, Search } from "lucide-react";
3import { type ComponentProps, type ReactNode, useState } from "react";
4
5import { cn } from "../../lib/cn";
6import { Popover, PopoverContent, PopoverTrigger } from "./popover";
7
8// shadcn/ui's command menu and combobox, styled with g1t's tokens: a select
9// with a search box, for lists too long to scroll. A `name` posts the
10// chosen value from a hidden input, so it works in a plain <Form>.
11
12export function Command({ className, ...props }: ComponentProps<typeof Cmdk>) {
13 return <Cmdk className={cn("flex w-full flex-col overflow-hidden text-sm text-fg", className)} {...props} />;
14}
15
16export function CommandInput({ className, ...props }: ComponentProps<typeof Cmdk.Input>) {
17 return (
18 <div className="flex items-center gap-2 border-b border-line px-3" cmdk-input-wrapper="">
19 <Search size={14} className="shrink-0 text-faint" />
20 <Cmdk.Input
21 className={cn("h-10 w-full bg-transparent text-sm outline-none placeholder:text-faint focus-visible:outline-none", className)}
22 {...props}
23 />
24 </div>
25 );
26}
27
28export function CommandList({ className, ...props }: ComponentProps<typeof Cmdk.List>) {
29 return <Cmdk.List className={cn("max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto p-1", className)} {...props} />;
30}
31
32export function CommandEmpty({ className, ...props }: ComponentProps<typeof Cmdk.Empty>) {
33 return <Cmdk.Empty className={cn("py-6 text-center text-sm text-faint", className)} {...props} />;
34}
35
36export function CommandGroup({ className, ...props }: ComponentProps<typeof Cmdk.Group>) {
37 return (
38 <Cmdk.Group
39 className={cn(
40 "[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-[0.6875rem] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:tracking-wide [&_[cmdk-group-heading]]:text-faint [&_[cmdk-group-heading]]:uppercase",
41 className,
42 )}
43 {...props}
44 />
45 );
46}
47
48export function CommandSeparator({ className, ...props }: ComponentProps<typeof Cmdk.Separator>) {
49 return <Cmdk.Separator className={cn("-mx-1 my-1 h-px bg-line", className)} {...props} />;
50}
51
52export function CommandItem({ className, ...props }: ComponentProps<typeof Cmdk.Item>) {
53 return (
54 <Cmdk.Item
55 className={cn(
56 "relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-fg/90 outline-none select-none",
57 "data-[selected=true]:bg-line data-[selected=true]:text-fg data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45",
58 "[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted",
59 className,
60 )}
61 {...props}
62 />
63 );
64}
65
66export type ComboboxOption = {
67 value: string;
68 label: string;
69 description?: ReactNode;
70 icon?: ReactNode;
71 /** More words that should find it. */
72 keywords?: string[];
73 disabled?: boolean;
74};
75
76/** A searchable select. Controlled with `value`, or not with `defaultValue`. */
77export function Combobox({
78 options,
79 value: controlled,
80 defaultValue = "",
81 onValueChange,
82 name,
83 disabled,
84 placeholder = "Choose…",
85 searchPlaceholder = "Search…",
86 emptyText = "Nothing matches.",
87 id,
88 className,
89 "aria-label": ariaLabel,
90}: {
91 options: ComboboxOption[];
92 value?: string;
93 defaultValue?: string;
94 onValueChange?: (value: string) => void;
95 name?: string;
96 disabled?: boolean;
97 placeholder?: string;
98 searchPlaceholder?: string;
99 emptyText?: string;
100 id?: string;
101 className?: string;
102 "aria-label"?: string;
103}) {
104 const [open, setOpen] = useState(false);
105 const [uncontrolled, setUncontrolled] = useState(defaultValue);
106 const value = controlled ?? uncontrolled;
107 const chosen = options.find((option) => option.value === value);
108 const choose = (next: string) => {
109 setUncontrolled(next);
110 onValueChange?.(next);
111 setOpen(false);
112 };
113 return (
114 <Popover open={open} onOpenChange={setOpen}>
115 {name && <input type="hidden" name={name} value={value} />}
116 <PopoverTrigger asChild>
117 <button
118 type="button"
119 id={id}
120 role="combobox"
121 aria-expanded={open}
122 aria-label={ariaLabel}
123 disabled={disabled}
124 className={cn(
125 "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",
126 "hover:border-line-strong focus-visible:border-accent-dim focus-visible:ring-2 focus-visible:ring-accent/25 focus-visible:outline-none",
127 "data-[state=open]:border-accent-dim data-[state=open]:ring-2 data-[state=open]:ring-accent/25 disabled:cursor-not-allowed disabled:opacity-50",
128 className,
129 )}
130 >
131 <span className={cn("flex min-w-0 items-center gap-2 truncate [&_svg]:size-4 [&_svg]:shrink-0", !chosen && "text-faint")}>
132 {chosen?.icon}
133 <span className="truncate">{chosen ? chosen.label : placeholder}</span>
134 </span>
135 <ChevronsUpDown size={14} className="shrink-0 text-faint" />
136 </button>
137 </PopoverTrigger>
138 <PopoverContent align="start" className="w-(--radix-popover-trigger-width) min-w-60 p-0">
139 <Command>
140 <CommandInput placeholder={searchPlaceholder} />
141 <CommandList>
142 <CommandEmpty>{emptyText}</CommandEmpty>
143 {options.map((option) => (
144 <CommandItem
145 key={option.value}
146 value={option.value}
147 keywords={[option.label, ...(option.keywords ?? [])]}
148 disabled={option.disabled}
149 onSelect={() => choose(option.value)}
150 className="items-start pr-8"
151 >
152 <span className="flex min-w-0 flex-col">
153 <span className="flex min-w-0 items-center gap-2">
154 {option.icon}
155 <span className="truncate">{option.label}</span>
156 </span>
157 {option.description && (
158 <span className={cn("mt-0.5 text-xs text-faint", option.icon != null && "pl-6")}>{option.description}</span>
159 )}
160 </span>
161 {option.value === value && <Check className="absolute top-2 right-2 size-3.5! text-accent!" />}
162 </CommandItem>
163 ))}
164 </CommandList>
165 </Command>
166 </PopoverContent>
167 </Popover>
168 );
169}