From c7bc4421c500110d71d8d0c45a8068b65ae39608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 16 Aug 2026 17:21:11 +0200 Subject: [PATCH] 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. --- docs/CONFIG_REFERENCE.md | 2 +- src/components/forms/controls/combobox.tsx | 78 ++++++++++++++++++++++ src/components/forms/field-renderer.tsx | 26 ++++++++ src/lib/config/schema.ts | 8 +++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/components/forms/controls/combobox.tsx diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 213114c..1602aad 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -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 diff --git a/src/components/forms/controls/combobox.tsx b/src/components/forms/controls/combobox.tsx new file mode 100644 index 0000000..0bc423a --- /dev/null +++ b/src/components/forms/controls/combobox.tsx @@ -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 ( + + + + {value || placeholder} + + + + + + + + No matches. + + {choices.map((choice) => ( + { + onChange(choice); + setOpen(false); + }} + > + + {choice} + + ))} + + + + + + ); +} diff --git a/src/components/forms/field-renderer.tsx b/src/components/forms/field-renderer.tsx index c39c002..ab8562a 100644 --- a/src/components/forms/field-renderer.tsx +++ b/src/components/forms/field-renderer.tsx @@ -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 }) { ); + case "combobox": + return ( + + + {label} + {variable.required && ( + * + )} + + + field.onChange(value)} + /> + + {variable.description && ( + {variable.description} + )} + + + ); + case "radio": return ( diff --git a/src/lib/config/schema.ts b/src/lib/config/schema.ts index ab4b7a2..1fe994e 100644 --- a/src/lib/config/schema.ts +++ b/src/lib/config/schema.ts @@ -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" &&