Skip to content
254 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
45 size={15}
46 className="text-faint transition-transform duration-200 group-data-[state=open]/select:rotate-180"
47 />
48 </Primitive.Icon>
49 </Primitive.Trigger>
50 );
51}
52
53export function SelectContent({
54 className,
55 children,
56 position = "popper",
57 sideOffset = 6,
58 ...props
59}: ComponentProps<typeof Primitive.Content>) {
60 return (
61 <Primitive.Portal>
62 <Primitive.Content
63 position={position}
64 sideOffset={position === "popper" ? sideOffset : undefined}
65 className={cn(
66 "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",
67 "origin-(--radix-select-content-transform-origin) data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
68 position === "popper" && "min-w-(--radix-select-trigger-width) max-w-[calc(100vw-2rem)]",
69 className,
70 )}
71 {...props}
72 >
73 <SelectScrollUpButton />
74 <Primitive.Viewport
75 className={cn("p-1", position === "popper" && "max-h-80 scroll-my-1")}
76 >
77 {children}
78 </Primitive.Viewport>
79 <SelectScrollDownButton />
80 </Primitive.Content>
81 </Primitive.Portal>
82 );
83}
84
85export function SelectLabel({ className, ...props }: ComponentProps<typeof Primitive.Label>) {
86 return (
87 <Primitive.Label
88 className={cn("px-2 pt-2 pb-1 text-[0.6875rem] font-medium tracking-wide text-faint uppercase", className)}
89 {...props}
90 />
91 );
92}
93
94/**
95 * One option. `icon` (an icon or an avatar) and the label show in the
96 * trigger once chosen; `description` shows only in the list.
97 */
98export function SelectItem({
99 className,
100 children,
101 icon,
102 description,
103 ...props
104}: ComponentProps<typeof Primitive.Item> & { icon?: ReactNode; description?: ReactNode }) {
105 return (
106 <Primitive.Item
107 className={cn(
108 "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",
109 "data-highlighted:bg-line data-highlighted:text-fg data-disabled:pointer-events-none data-disabled:opacity-45",
110 "data-[state=checked]:text-fg",
111 className,
112 )}
113 {...props}
114 >
115 <span className="flex min-w-0 flex-col">
116 <Primitive.ItemText>
117 <span className="flex min-w-0 items-center gap-2 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted">
118 {icon}
119 <span className="truncate">{children}</span>
120 </span>
121 </Primitive.ItemText>
122 {description && (
123 <span className={cn("mt-0.5 text-xs leading-snug text-faint", icon != null && "pl-6")}>{description}</span>
124 )}
125 </span>
126 <span className="absolute top-2 right-2 flex size-4 items-center justify-center">
127 <Primitive.ItemIndicator>
128 <Check size={14} className="text-accent" />
129 </Primitive.ItemIndicator>
130 </span>
131 </Primitive.Item>
132 );
133}
134
135export function SelectSeparator({ className, ...props }: ComponentProps<typeof Primitive.Separator>) {
136 return <Primitive.Separator className={cn("-mx-1 my-1 h-px bg-line", className)} {...props} />;
137}
138
139export function SelectScrollUpButton({ className, ...props }: ComponentProps<typeof Primitive.ScrollUpButton>) {
140 return (
141 <Primitive.ScrollUpButton
142 className={cn("flex cursor-default items-center justify-center py-1 text-faint", className)}
143 {...props}
144 >
145 <ChevronUp size={14} />
146 </Primitive.ScrollUpButton>
147 );
148}
149
150export function SelectScrollDownButton({ className, ...props }: ComponentProps<typeof Primitive.ScrollDownButton>) {
151 return (
152 <Primitive.ScrollDownButton
153 className={cn("flex cursor-default items-center justify-center py-1 text-faint", className)}
154 {...props}
155 >
156 <ChevronDown size={14} />
157 </Primitive.ScrollDownButton>
158 );
159}
160
161export type SelectOption = { value: string; label: ReactNode; disabled?: boolean; description?: ReactNode; icon?: ReactNode };
162
163/** Radix items cannot have "" as a value; this stands in for it inside. */
164const EMPTY = "__g1t_empty__";
165const inside = (value: string | undefined) => (value === "" ? EMPTY : value);
166const outside = (value: string) => (value === EMPTY ? "" : value);
167
168/**
169 * A select for what used to be a native <select>: a list of options, posted
170 * by `name` with its form. Options may have "" as their value (Any, None),
171 * which posts "" as a native select would. `onValueChange` sees the new
172 * value at once; `afterChange` runs once the form's fields hold it, so it
173 * can submit the form (filters that apply as they change).
174 */
175export function SelectField({
176 options,
177 name,
178 form,
179 value,
180 defaultValue,
181 onValueChange,
182 afterChange,
183 placeholder,
184 required,
185 disabled,
186 size,
187 id,
188 className,
189 contentClassName,
190 itemClassName,
191 align,
192 "aria-label": ariaLabel,
193 "aria-labelledby": ariaLabelledBy,
194}: {
195 options: SelectOption[];
196 name?: string;
197 form?: string;
198 value?: string;
199 defaultValue?: string;
200 onValueChange?: (value: string) => void;
201 afterChange?: (value: string) => void;
202 placeholder?: ReactNode;
203 required?: boolean;
204 disabled?: boolean;
205 size?: "sm" | "default" | "lg";
206 id?: string;
207 className?: string;
208 contentClassName?: string;
209 itemClassName?: string;
210 align?: "start" | "center" | "end";
211 "aria-label"?: string;
212 "aria-labelledby"?: string;
213}) {
214 const [own, setOwn] = useState(defaultValue);
215 const current = value !== undefined ? value : own;
216 // With a "" option, a hidden input posts the value, so "" posts as "".
217 const empty = options.some((option) => option.value === "");
218 return (
219 <>
220 <Select
221 value={(empty ? inside(current) : current) ?? ""}
222 onValueChange={(next) => {
223 const chosen = outside(next);
224 setOwn(chosen);
225 onValueChange?.(chosen);
226 if (afterChange) setTimeout(() => afterChange(chosen), 0);
227 }}
228 name={empty ? undefined : name}
229 form={empty ? undefined : form}
230 required={required}
231 disabled={disabled}
232 >
233 <SelectTrigger id={id} size={size} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} className={className}>
234 <SelectValue placeholder={placeholder} />
235 </SelectTrigger>
236 <SelectContent className={contentClassName} align={align}>
237 {options.map((option) => (
238 <SelectItem
239 key={option.value}
240 value={inside(option.value)!}
241 disabled={option.disabled}
242 description={option.description}
243 icon={option.icon}
244 className={itemClassName}
245 >
246 {option.label}
247 </SelectItem>
248 ))}
249 </SelectContent>
250 </Select>
251 {empty && name && <input type="hidden" name={name} form={form} value={current ?? ""} disabled={disabled} />}
252 </>
253 );
254}