Skip to content

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

228 linesCodeBlameRaw
1import { Check, ChevronDown, ChevronUp } from "lucide-react";
2import { Select as Primitive } from "radix-ui";
3import { type ComponentProps, type ReactNode, useState } from "react";
4
5import { cn } from "../../lib/cn";
6
7// shadcn/ui's select, styled with g1t's tokens.
8//
9// Inside a <Form>, give <Select> a `name` and Radix renders a hidden native
10// <select> beside the trigger, so the value posts like any other field.
11// An item's value can never be "": use a word such as "none" and map it.
12
13export const Select = Primitive.Root;
14export const SelectGroup = Primitive.Group;
15export const SelectValue = Primitive.Value;
16
17const TRIGGER_SIZES = { sm: "h-8 px-2.5 text-[0.8125rem]", default: "h-9", lg: "h-11" };
18
19/** The control people see: the chosen value, or the placeholder, and a chevron. */
20export function SelectTrigger({
21 className,
22 size = "default",
23 children,
24 ...props
25}: ComponentProps<typeof Primitive.Trigger> & { size?: "sm" | "default" | "lg" }) {
26 return (
27 <Primitive.Trigger
28 data-size={size}
29 className={cn(
30 "group/select flex w-full min-w-0 items-center justify-between gap-2 rounded-md border border-line bg-bg px-3 text-left text-sm whitespace-nowrap text-fg outline-none transition-colors",
31 TRIGGER_SIZES[size],
32 "hover:border-line-strong focus-visible:border-accent-dim focus-visible:ring-2 focus-visible:ring-accent/25 focus-visible:outline-none",
33 "data-[state=open]:border-accent-dim data-[state=open]:ring-2 data-[state=open]:ring-accent/25",
34 "data-placeholder:text-faint aria-invalid:border-danger/70",
35 "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-line",
36 "[&>span]:flex [&>span]:min-w-0 [&>span]:items-center [&>span]:gap-2 [&>span]:truncate",
37 "[&_svg]:shrink-0",
38 className,
39 )}
40 {...props}
41 >
42 {children}
43 <Primitive.Icon asChild>
44 <ChevronDown size={15} className="text-faint transition-transform duration-200 group-data-[state=open]/select:rotate-180" />
45 </Primitive.Icon>
46 </Primitive.Trigger>
47 );
48}
49
50export function SelectContent({ className, children, position = "popper", sideOffset = 6, ...props }: ComponentProps<typeof Primitive.Content>) {
51 return (
52 <Primitive.Portal>
53 <Primitive.Content
54 position={position}
55 sideOffset={position === "popper" ? sideOffset : undefined}
56 className={cn(
57 "relative z-50 max-h-(--radix-select-content-available-height) min-w-36 overflow-hidden rounded-lg border border-line-strong bg-raised text-sm text-fg shadow-xl shadow-black/40",
58 "origin-(--radix-select-content-transform-origin) data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
59 position === "popper" && "min-w-(--radix-select-trigger-width) max-w-[calc(100vw-2rem)]",
60 className,
61 )}
62 {...props}
63 >
64 <SelectScrollUpButton />
65 <Primitive.Viewport className={cn("p-1", position === "popper" && "max-h-80 scroll-my-1")}>{children}</Primitive.Viewport>
66 <SelectScrollDownButton />
67 </Primitive.Content>
68 </Primitive.Portal>
69 );
70}
71
72export function SelectLabel({ className, ...props }: ComponentProps<typeof Primitive.Label>) {
73 return <Primitive.Label className={cn("px-2 pt-2 pb-1 text-[0.6875rem] font-medium tracking-wide text-faint uppercase", className)} {...props} />;
74}
75
76/**
77 * One option. `icon` (an icon or an avatar) and the label show in the
78 * trigger once chosen; `description` shows only in the list.
79 */
80export function SelectItem({
81 className,
82 children,
83 icon,
84 description,
85 ...props
86}: ComponentProps<typeof Primitive.Item> & { icon?: ReactNode; description?: ReactNode }) {
87 return (
88 <Primitive.Item
89 className={cn(
90 "relative flex w-full cursor-default items-start gap-2 rounded-md py-1.5 pr-8 pl-2 text-fg/90 outline-none select-none",
91 "data-highlighted:bg-line data-highlighted:text-fg data-disabled:pointer-events-none data-disabled:opacity-45",
92 "data-[state=checked]:text-fg",
93 className,
94 )}
95 {...props}
96 >
97 <span className="flex min-w-0 flex-col">
98 <Primitive.ItemText>
99 <span className="flex min-w-0 items-center gap-2 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted">
100 {icon}
101 <span className="truncate">{children}</span>
102 </span>
103 </Primitive.ItemText>
104 {description && <span className={cn("mt-0.5 text-xs leading-snug text-faint", icon != null && "pl-6")}>{description}</span>}
105 </span>
106 <span className="absolute top-2 right-2 flex size-4 items-center justify-center">
107 <Primitive.ItemIndicator>
108 <Check size={14} className="text-accent" />
109 </Primitive.ItemIndicator>
110 </span>
111 </Primitive.Item>
112 );
113}
114
115export function SelectSeparator({ className, ...props }: ComponentProps<typeof Primitive.Separator>) {
116 return <Primitive.Separator className={cn("-mx-1 my-1 h-px bg-line", className)} {...props} />;
117}
118
119export function SelectScrollUpButton({ className, ...props }: ComponentProps<typeof Primitive.ScrollUpButton>) {
120 return (
121 <Primitive.ScrollUpButton className={cn("flex cursor-default items-center justify-center py-1 text-faint", className)} {...props}>
122 <ChevronUp size={14} />
123 </Primitive.ScrollUpButton>
124 );
125}
126
127export function SelectScrollDownButton({ className, ...props }: ComponentProps<typeof Primitive.ScrollDownButton>) {
128 return (
129 <Primitive.ScrollDownButton className={cn("flex cursor-default items-center justify-center py-1 text-faint", className)} {...props}>
130 <ChevronDown size={14} />
131 </Primitive.ScrollDownButton>
132 );
133}
134
135export type SelectOption = { value: string; label: ReactNode; disabled?: boolean; description?: ReactNode; icon?: ReactNode };
136
137/** Radix items cannot have "" as a value; this stands in for it inside. */
138const EMPTY = "__g1t_empty__";
139const inside = (value: string | undefined) => (value === "" ? EMPTY : value);
140const outside = (value: string) => (value === EMPTY ? "" : value);
141
142/**
143 * A select for what used to be a native <select>: a list of options, posted
144 * by `name` with its form. Options may have "" as their value (Any, None),
145 * which posts "" as a native select would. `onValueChange` sees the new
146 * value at once; `afterChange` runs once the form's fields hold it, so it
147 * can submit the form (filters that apply as they change).
148 */
149export function SelectField({
150 options,
151 name,
152 form,
153 value,
154 defaultValue,
155 onValueChange,
156 afterChange,
157 placeholder,
158 required,
159 disabled,
160 size,
161 id,
162 className,
163 contentClassName,
164 itemClassName,
165 align,
166 "aria-label": ariaLabel,
167 "aria-labelledby": ariaLabelledBy,
168}: {
169 options: SelectOption[];
170 name?: string;
171 form?: string;
172 value?: string;
173 defaultValue?: string;
174 onValueChange?: (value: string) => void;
175 afterChange?: (value: string) => void;
176 placeholder?: ReactNode;
177 required?: boolean;
178 disabled?: boolean;
179 size?: "sm" | "default" | "lg";
180 id?: string;
181 className?: string;
182 contentClassName?: string;
183 itemClassName?: string;
184 align?: "start" | "center" | "end";
185 "aria-label"?: string;
186 "aria-labelledby"?: string;
187}) {
188 const [own, setOwn] = useState(defaultValue);
189 const current = value !== undefined ? value : own;
190 // With a "" option, a hidden input posts the value, so "" posts as "".
191 const empty = options.some((option) => option.value === "");
192 return (
193 <>
194 <Select
195 value={(empty ? inside(current) : current) ?? ""}
196 onValueChange={(next) => {
197 const chosen = outside(next);
198 setOwn(chosen);
199 onValueChange?.(chosen);
200 if (afterChange) setTimeout(() => afterChange(chosen), 0);
201 }}
202 name={empty ? undefined : name}
203 form={empty ? undefined : form}
204 required={required}
205 disabled={disabled}
206 >
207 <SelectTrigger id={id} size={size} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} className={className}>
208 <SelectValue placeholder={placeholder} />
209 </SelectTrigger>
210 <SelectContent className={contentClassName} align={align}>
211 {options.map((option) => (
212 <SelectItem
213 key={option.value}
214 value={inside(option.value)!}
215 disabled={option.disabled}
216 description={option.description}
217 icon={option.icon}
218 className={itemClassName}
219 >
220 {option.label}
221 </SelectItem>
222 ))}
223 </SelectContent>
224 </Select>
225 {empty && name && <input type="hidden" name={name} form={form} value={current ?? ""} disabled={disabled} />}
226 </>
227 );
228}