Add a searchable combobox control for enum variables

select/radio don't scale to enums with dozens of choices. Reuses the
same cmdk Command/Popover primitives multi-select already uses, just
single-valued instead of an array.
This commit is contained in:
2026-08-16 17:21:11 +02:00
parent a496dc4865
commit c7bc4421c5
4 changed files with 113 additions and 1 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ Type-specific fields:
| `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` |
| `number` | `number` | `slider` (requires both `min` and `max`) |
| `boolean` | `checkbox` | `switch` |
| `enum` | `select` | `radio` |
| `enum` | `select` | `radio`, `combobox` (searchable, single-select) |
| `multiselect` | `multiselect` (combobox) | `checkboxGroup` |
### `passAs` semantics
@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface ComboboxProps {
choices: string[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function Combobox({
choices,
value,
onChange,
placeholder = "Select...",
}: ComboboxProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
buttonVariants({ variant: "outline" }),
"h-auto min-h-8 w-full justify-between font-normal",
)}
>
<span className={cn("flex-1 text-left", !value && "text-muted-foreground")}>
{value || placeholder}
</span>
<ChevronsUpDown className="text-muted-foreground size-4 shrink-0" />
</PopoverTrigger>
<PopoverContent className="w-80 p-0">
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No matches.</CommandEmpty>
<CommandGroup>
{choices.map((choice) => (
<CommandItem
key={choice}
onSelect={() => {
onChange(choice);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 size-4",
value === choice ? "opacity-100" : "opacity-0",
)}
/>
{choice}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+26
View File
@@ -24,6 +24,7 @@ import {
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { MultiSelect } from "./controls/multi-select";
import { Combobox } from "./controls/combobox";
import type { ClientVariable } from "@/lib/config/serialize";
export function FieldRenderer({ variable }: { variable: ClientVariable }) {
@@ -211,6 +212,31 @@ export function FieldRenderer({ variable }: { variable: ClientVariable }) {
</FormItem>
);
case "combobox":
return (
<FormItem>
<FormLabel>
{label}
{variable.required && (
<span className="text-destructive"> *</span>
)}
</FormLabel>
<FormControl>
<Combobox
choices={
variable.type === "enum" ? variable.choices : []
}
value={field.value ?? ""}
onChange={(value) => field.onChange(value)}
/>
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
case "radio":
return (
<FormItem>
+8
View File
@@ -18,6 +18,7 @@ const controlSchema = z.enum([
"switch",
"select",
"radio",
"combobox",
"multiselect",
"checkboxGroup",
]);
@@ -115,6 +116,13 @@ const variableWithChecks = variableSchema.superRefine((variable, ctx) => {
path: ["control"],
});
}
if (variable.control === "combobox" && variable.type !== "enum") {
ctx.addIssue({
code: "custom",
message: "control 'combobox' requires type 'enum'",
path: ["control"],
});
}
if (
variable.control === "slider" &&
variable.type === "number" &&