OfficeGest
Form Builder/Field Types

Choice & Boolean Fields

select, radio, checkbox, checkboxGroup, switch, combobox, and asyncCombobox.

The seven field types on this page cover picking from a set of options and toggling state. Three are boolean affordances (checkbox, switch, and the multi-select checkboxGroup), and four are choice fields that read from an option list (select, radio, combobox, asyncCombobox).

The choice fields differ only in their affordance and how the option list is sourced: select is a compact dropdown, radio shows every option at once (no dropdown), combobox adds typeahead filtering for longer lists, and asyncCombobox defers the list to a server query. All four — plus checkboxGroup — share the same SelectOption shape.

Every field config also accepts the shared base props — label, description, placeholder, colSpan, disabled, required, visible, validate, validateAsync, and validateAsyncDebounceMs. The tables below list only the props specific to each field type. label, description, placeholder, and every option label are i18n keys, resolved at render time.


The SelectOption shape

select, radio, combobox, and checkboxGroup all take an options array of SelectOption:

type SelectOption = {
    label: string;
    value: string | number;
};

label is an i18n key resolved at render time; value is what gets stored in form state.

A key behaviour shared by all four list-driven renderers: if any option declares a numeric value, the stored value is a number; otherwise it is a string. The renderers detect this once per render (config.options.some(opt => typeof opt.value === "number")) and coerce on the way out. Internally the underlying Radix/cmdk controls always operate on strings, so the renderer stringifies on read and converts back on change. Your Zod schema should match: use z.enum([...]) or z.string() for string-valued options and z.number() (or z.coerce.number()) for numeric ones — don't mix string and number values in a single option list.

Static vs. function-form options

options accepts either a static SelectOption[] or a predicate (values) => SelectOption[] that recomputes the list whenever sibling field values change. The static form is shown throughout this page; the predicate form (dependent dropdowns) is covered in Conditional Logic.

A function-form options predicate may run on every keystroke. If it returns a fresh array literal each call, the option DOM is re-created each time. Keep the option set in a stable module-scope constant that the predicate selects from, or memoize the result.


select

A compact single-choice dropdown built on Radix Select. Best for short lists (roughly six options or fewer) where typeahead filtering isn't needed.

PropTypeDefaultNotes
type"select"Required discriminator.
optionsSelectOption[] or (values) => SelectOption[]The choices. Required.

The stored value is a string or number (see the value-coercion rule). Radix Select has no root onBlur, so blur-mode validation fires when the dropdown closes.

const COUNTRY_OPTIONS: SelectOption[] = [
    {label: "Portugal", value: "PT"},
    {label: "Spain", value: "ES"},
    {label: "France", value: "FR"},
];

const schema = z.object({
    country: z.enum(["PT", "ES", "FR"]),
});

const fields: FormFields<typeof schema> = {
    country: {
        type: "select",
        label: "Country",
        description: "Radix Select — compact, no search.",
        placeholder: "Pick one…",
        options: COUNTRY_OPTIONS,
    },
};

Radix reserves the empty string internally and warns when it appears as a controlled value, so the renderer coerces empty / null / undefined to no selection (the placeholder shows). Provide a real defaultValue if you want a pre-selected option.


radio

Single-choice rendered as a vertical group of radio buttons via Radix RadioGroup. Each option gets its own clickable label. Good when you want every option visible at once rather than hidden behind a dropdown.

PropTypeDefaultNotes
type"radio"Required discriminator.
optionsSelectOption[] or (values) => SelectOption[]The choices. Required.

Same value-coercion rule as select. Blur is wired on the wrapping element (Radix RadioGroup has no root blur callback, and per-item blur would fire repeatedly during arrow-key navigation).

const schema = z.object({
    priority: z.enum(["low", "normal", "high"]),
});

const fields: FormFields<typeof schema> = {
    priority: {
        type: "radio",
        label: "Priority",
        description: "Single-choice via RadioGroup. One option must be selected.",
        options: [
            {label: "Low", value: "low"},
            {label: "Normal", value: "normal"},
            {label: "High", value: "high"},
        ],
    },
};

combobox

A searchable single-select built on Popover + Command (cmdk). It renders a trigger button showing the selected option's label (or the placeholder), and opens a popover with a search input and a filtered list. Reach for combobox over select once the list grows past a screenful.

PropTypeDefaultNotes
type"combobox"Required discriminator.
optionsSelectOption[] or (values) => SelectOption[]The choices. Required.

The stored value follows the same coercion rule as select and radio. The cmdk filter matches both the underlying value and the visible label (the label is registered as a keyword), so typing either matches an item. Selecting commits the value and closes the popover; popover close fires blur-mode validation.

const schema = z.object({
    country: z.enum(["PT", "ES", "FR", "IT", "DE", "NL", "BE", "CH"]),
});

const fields: FormFields<typeof schema> = {
    country: {
        type: "combobox",
        label: "Country",
        description: "Popover + Command — type to filter.",
        placeholder: "Search countries…",
        options: COUNTRY_OPTIONS,
    },
};

This renderer composes Popover + Command directly rather than using the Base-UI <Combobox> from @/components/ui/combobox, which has portal/DismissableLayer incompatibilities inside Sheets. That means combobox fields work correctly inside Sheets and other dismissable-layer contexts.


asyncCombobox

Same UX as combobox (Popover + Command + typeahead) but the option list comes from a queryFn instead of a static array. Use it for large or remote catalogs — customers, products, anything you wouldn't ship as a static list.

PropTypeDefaultNotes
type"asyncCombobox"Required discriminator.
queryKeyQueryKeyReact Query cache key. The debounced search term is appended internally, so the effective key is [...queryKey, search]. Required.
queryFn(search: string) => Promise<SelectOption[]>Async fetcher. Receives the debounced search string and resolves to the option list. Required.
debounceMsnumber300Debounce window applied to the search input before a query fires.
minCharsnumber0Minimum search length before a query fires. 0 means an empty search fires on first open ("show me everything"); set 1+ for endpoints that reject empty searches.

The stored value is a string or number (matching SelectOption["value"]).

const schema = z.object({
    customer: z.string().min(1),
});

async function searchCustomers(search: string): Promise<SelectOption[]> {
    const res = await fetch(`/api/customers?q=${encodeURIComponent(search)}`);
    const customers = await res.json();
    return customers.map((c) => ({label: c.name, value: c.id}));
}

const fields: FormFields<typeof schema> = {
    customer: {
        type: "asyncCombobox",
        label: "Customer",
        description: "Debounced server lookup.",
        placeholder: "Search customers…",
        queryKey: ["customers", "search"],
        queryFn: searchCustomers,
        debounceMs: 300,
        minChars: 0,
    },
};

How debouncing and the label cache work

The data flow is:

The user types in the search input, updating local search state.
search is debounced by debounceMs (default 300ms) into debouncedSearch.
A React Query keyed on [...queryKey, debouncedSearch] runs queryFn(debouncedSearch). It is gated by enabled: open && debouncedSearch.length >= minChars, so it only fires while the popover is open and the search meets minChars. Results are cached for 30 seconds.
Returned options render in the list.

Because the form stores only the selected value, the primitive keeps an internal value → label cache (a Map) so the trigger can display the selected option's label across opens and across search-term changes. The cache populates as options stream in from queries.

For a value the combobox has never seen — for example a defaultValue set before any query has run — the trigger falls back to showing the raw value string until the user opens the popover and a query resolves with options that include it. If you preload edit forms with a pre-selected async value, expect the raw id to flash until the first query resolves, or seed the React Query cache for queryKey ahead of time.


checkbox

A single boolean checkbox. The label sits beside the control (horizontal layout). The stored value is a boolean.

PropTypeDefaultNotes
type"checkbox"Required discriminator.
const schema = z.object({
    accepts_terms: z.boolean().refine((v) => v === true, "You must accept the terms"),
});

const fields: FormFields<typeof schema> = {
    accepts_terms: {
        type: "checkbox",
        label: "I accept the terms",
        description: "Required to submit.",
    },
};

There is no indeterminate state. Radix can emit "indeterminate" from onCheckedChange, but the renderer coerces it to false (only an explicit true counts as checked). An empty / unset value is treated as false.


switch

A boolean toggle. Functionally identical to checkbox — same boolean value, same horizontal layout — but semantically different: a switch reads as toggling a setting (on/off), while a checkbox reads as marking a state or acknowledging something.

PropTypeDefaultNotes
type"switch"Required discriminator.
const schema = z.object({
    notifications: z.boolean(),
});

const fields: FormFields<typeof schema> = {
    notifications: {
        type: "switch",
        label: "Enable notifications",
        description: "Push notifications for new messages and assignments.",
    },
};

checkboxGroup

Multi-selection rendered as a vertical column of checkboxes. The stored value is an array — (string | number)[] — following the same numeric/string coercion rule as the single-choice fields. Toggling an option adds or removes its value; a newly-checked value is appended to the end of the array, so the stored order reflects the order options were toggled, not the source-list order.

PropTypeDefaultNotes
type"checkboxGroup"Required discriminator.
optionsSelectOption[] or (values) => SelectOption[]The choices. Required.

The group label is the legend of the whole set (it doesn't htmlFor any single input), so this renderer uses a FieldSet + FieldLegend instead of the single-input field layout. The default value should be an array ([] for none selected), not undefined.

const schema = z.object({
    tags: z.array(z.enum(["urgent", "followup", "review"])).min(1, "Pick at least one tag"),
});

const fields: FormFields<typeof schema> = {
    tags: {
        type: "checkboxGroup",
        label: "Tags",
        description: "Multi-choice — pick any combination.",
        options: [
            {label: "Urgent", value: "urgent"},
            {label: "Follow-up", value: "followup"},
            {label: "Needs review", value: "review"},
        ],
    },
};

Putting it together

The playground's boolean section combines switch, checkbox, radio, and checkboxGroup in one form (the hidden field carries a CSRF token invisibly through to submission):

const schema = z.object({
    notifications: z.boolean(),
    marketing_emails: z.boolean(),
    accepts_terms: z.boolean().refine((v) => v === true, "You must accept the terms"),
    priority: z.enum(["low", "normal", "high"]),
    tags: z.array(z.enum(["urgent", "followup", "review"])).min(1, "Pick at least one tag"),
    csrf_token: z.string().min(1),
});

const fields: FormFields<typeof schema> = {
    notifications: {type: "switch", label: "Enable notifications", colSpan: {base: 12, md: 6}},
    marketing_emails: {type: "switch", label: "Marketing emails", colSpan: {base: 12, md: 6}},
    accepts_terms: {type: "checkbox", label: "I accept the terms", description: "Required to submit."},
    priority: {
        type: "radio",
        label: "Priority",
        options: [
            {label: "Low", value: "low"},
            {label: "Normal", value: "normal"},
            {label: "High", value: "high"},
        ],
    },
    tags: {
        type: "checkboxGroup",
        label: "Tags",
        options: [
            {label: "Urgent", value: "urgent"},
            {label: "Follow-up", value: "followup"},
            {label: "Needs review", value: "review"},
        ],
    },
    csrf_token: {type: "hidden"},
};

const form = useFormBuilder({
    formId: "fields-boolean",
    schema,
    fields,
    defaultValues: {
        notifications: true,
        marketing_emails: false,
        accepts_terms: false,
        priority: "normal",
        tags: [],
        csrf_token: "demo-csrf-token-1234",
    },
    onSubmit: (values) => console.log(values),
});

Hidden fields are still validated

Any of these fields can be conditionally hidden with a visible predicate. A hidden field keeps its stored value and is still validated on submit — visibility does not skip validation.

If a choice or boolean field can be hidden, its schema branch must accept the value it would hold while hidden — make it .optional() / .nullable(), or model the branch with z.discriminatedUnion. Otherwise a hidden field whose value fails its rule (an empty checkboxGroup array under .min(1), an unselected z.enum, an unchecked .refine(v => v === true)) blocks submission with no visible field to show the error, and the Submit button appears inert.

const schema = z.object({
    wants_tags: z.boolean(),
    tags: z.array(z.enum(["urgent", "followup", "review"])).optional(),
});

const fields: FormFields<typeof schema> = {
    wants_tags: {type: "checkbox", label: "Add tags"},
    tags: {
        type: "checkboxGroup",
        label: "Tags",
        options: [
            {label: "Urgent", value: "urgent"},
            {label: "Follow-up", value: "followup"},
            {label: "Needs review", value: "review"},
        ],
        visible: (values) => values.wants_tags === true,
    },
};