@letar/forms

Selection Fields

Select, Combobox, RadioGroup, Checkbox, and more

Full example

The complete sandbox example (all select-family fields on one page), read directly from the form-develop-app / form-develop-app-shadcn source at build time — switch skins to see the same fields in both implementations.

apps/form-develop-app/src/app/select-demo/page.tsx
'use client'

import { Box, Heading, Text, VStack } from '@chakra-ui/react'
import { Form } from '@letar/forms'
import { useState } from 'react'
import { z } from 'zod/v4'
import { DemoPageLayout, SubmittedDataPreview } from '../_components'

/**
 * Демо-схема для NativeSelect и CascadingSelect
 */
const DemoSchema = z
  .object({
    // NativeSelect fields
    simpleSelect: z.string().meta({
      ui: { title: 'Simple NativeSelect', placeholder: 'Select option...' },
    }),
    sizeSelect: z.string().meta({
      ui: { title: 'Size Selection' },
    }),
    prioritySelect: z
      .string()
      .optional()
      .meta({
        ui: { title: 'Priority (optional)' },
      }),

    // CascadingSelect fields
    country: z.string().meta({
      ui: { title: 'Country', placeholder: 'Select country...' },
    }),
    city: z
      .string()
      .optional()
      .meta({
        ui: { title: 'City', placeholder: 'Select city...' },
      }),

    // Вложенные поля
    address: z.object({
      region: z.string().meta({
        ui: { title: 'Region', placeholder: 'Select region...' },
      }),
      district: z
        .string()
        .optional()
        .meta({
          ui: { title: 'District', placeholder: 'Select district...' },
        }),
    }),

    // Group.Field.Select с getGroup (optgroup)
    technology: z
      .string()
      .optional()
      .meta({
        ui: { title: 'Technology (grouped)', placeholder: 'Select technology...' },
      }),
  })
  .strip()

type DemoData = z.infer<typeof DemoSchema>

const initialData: DemoData = {
  simpleSelect: '',
  sizeSelect: 'md',
  prioritySelect: undefined,
  country: '',
  city: undefined,
  address: {
    region: '',
    district: undefined,
  },
  technology: undefined,
}

// Данные для NativeSelect
const simpleOptions = [
  { title: 'Option 1', value: 'opt1' },
  { title: 'Option 2', value: 'opt2' },
  { title: 'Option 3', value: 'opt3' },
]

const sizeOptions = [
  { title: 'Extra Small', value: 'xs' },
  { title: 'Small', value: 'sm' },
  { title: 'Medium', value: 'md' },
  { title: 'Large', value: 'lg' },
]

const priorityOptions = [
  { title: 'Low Priority', value: 'low' },
  { title: 'Medium Priority', value: 'medium' },
  { title: 'High Priority', value: 'high' },
  { title: 'Critical', value: 'critical' },
]

// Данные для CascadingSelect
const countries = [
  { label: 'Russia', value: 'ru' },
  { label: 'USA', value: 'us' },
  { label: 'Germany', value: 'de' },
]

const citiesByCountry: Record<string, { label: string; value: string }[]> = {
  ru: [
    { label: 'Moscow', value: 'msk' },
    { label: 'Saint Petersburg', value: 'spb' },
    { label: 'Novosibirsk', value: 'nsk' },
  ],
  us: [
    { label: 'New York', value: 'nyc' },
    { label: 'Los Angeles', value: 'la' },
    { label: 'Chicago', value: 'chi' },
  ],
  de: [
    { label: 'Berlin', value: 'ber' },
    { label: 'Munich', value: 'mun' },
    { label: 'Hamburg', value: 'ham' },
  ],
}

const regions = [
  { label: 'Moscow Region', value: 'msk_reg' },
  { label: 'Leningrad Region', value: 'len_reg' },
  { label: 'Krasnodar Region', value: 'krd_reg' },
]

// Данные для группированного Select (getGroup)
const technologies = [
  { label: 'React', value: 'react', category: 'Frontend' },
  { label: 'Vue', value: 'vue', category: 'Frontend' },
  { label: 'Svelte', value: 'svelte', category: 'Frontend' },
  { label: 'Express', value: 'express', category: 'Backend' },
  { label: 'NestJS', value: 'nestjs', category: 'Backend' },
  { label: 'React Native', value: 'react-native', category: 'Mobile' },
]

const districtsByRegion: Record<string, { label: string; value: string }[]> = {
  msk_reg: [
    { label: 'Odintsovo', value: 'odin' },
    { label: 'Khimki', value: 'khim' },
    { label: 'Balashikha', value: 'bal' },
  ],
  len_reg: [
    { label: 'Vsevolozhsk', value: 'vsev' },
    { label: 'Gatchina', value: 'gat' },
    { label: 'Vyborg', value: 'vyb' },
  ],
  krd_reg: [
    { label: 'Sochi', value: 'soc' },
    { label: 'Novorossiysk', value: 'nov' },
    { label: 'Anapa', value: 'ana' },
  ],
}

export default function SelectDemoPage() {
  const [submitted, setSubmitted] = useState<DemoData | null>(null)

  return (
    <DemoPageLayout title="Select Demo" description="NativeSelect и CascadingSelect компоненты" maxW="800px">
      <Form
        schema={DemoSchema}
        initialValue={initialData}
        onSubmit={(data) => {
          setSubmitted(data)
        }}
      >
        {/* NativeSelect секция */}
        <Box borderWidth={1} borderRadius="md" p={4} mb={6}>
          <Heading size="md" mb={4}>
            NativeSelect
          </Heading>
          <Text color="fg.muted" mb={4}>
            Нативный браузерный select для лучшего UX на мобильных устройствах
          </Text>
          <VStack gap={4} align="stretch">
            <Form.Field.NativeSelect name="simpleSelect" options={simpleOptions} />

            <Form.Field.NativeSelect name="sizeSelect" options={sizeOptions} />

            <Form.Field.NativeSelect name="prioritySelect" options={priorityOptions} />
          </VStack>
        </Box>

        {/* CascadingSelect секция */}
        <Box borderWidth={1} borderRadius="md" p={4} mb={6}>
          <Heading size="md" mb={4}>
            CascadingSelect
          </Heading>
          <Text color="fg.muted" mb={4}>
            Каскадный select с зависимостью от другого поля (Страна → Город)
          </Text>
          <VStack gap={4} align="stretch">
            <Form.Field.Select name="country" options={countries} />

            <Form.Field.CascadingSelect
              name="city"
              dependsOn="country"
              loadOptions={async (parentValue) => {
                // Имитация загрузки с сервера
                await new Promise((r) => setTimeout(r, 300))
                const countryCode = parentValue as string | undefined
                if (!countryCode) {
                  return []
                }
                return citiesByCountry[countryCode] ?? []
              }}
            />
          </VStack>
        </Box>

        {/* Вложенные CascadingSelect */}
        <Box borderWidth={1} borderRadius="md" p={4} mb={6}>
          <Heading size="md" mb={4}>
            Nested CascadingSelect
          </Heading>
          <Text color="fg.muted" mb={4}>
            Каскадные select с вложенными путями (address.region → address.district)
          </Text>
          <VStack gap={4} align="stretch">
            <Form.Field.Select name="address.region" options={regions} />

            <Form.Field.CascadingSelect
              name="address.district"
              dependsOn="address.region"
              loadOptions={async (parentValue) => {
                await new Promise((r) => setTimeout(r, 200))
                const regionCode = parentValue as string | undefined
                if (!regionCode) {
                  return []
                }
                return districtsByRegion[regionCode] ?? []
              }}
            />
          </VStack>
        </Box>

        {/* Группированный Select (getGroup) */}
        <Box borderWidth={1} borderRadius="md" p={4} mb={6}>
          <Heading size="md" mb={4}>
            Grouped Select (getGroup)
          </Heading>
          <Text color="fg.muted" mb={4}>
            Опции сгруппированы по категории (optgroup) — симметрично группировке в Form.Field.Combobox
          </Text>
          <Form.Field.Select
            name="technology"
            options={technologies}
            getGroup={(opt) => (opt as (typeof technologies)[number]).category}
          />
        </Box>

        <Form.Button.Submit>Submit</Form.Button.Submit>
      </Form>

      <SubmittedDataPreview data={submitted} />
    </DemoPageLayout>
  )
}

Select

API: Form.Field.Select

Dropdown select for choosing from a list of options.

const Schema = z.object({
  category: z.enum(['electronics', 'clothing', 'books']).meta({
    ui: { title: 'Category' },
  }),
})

<Form.Field.Select name="category" />

Options are automatically generated from z.enum(). For custom labels:

const Schema = z.object({
  status: z.enum(['active', 'inactive', 'pending']).meta({
    ui: {
      title: 'Status',
      options: [
        { value: 'active', label: 'Active' },
        { value: 'inactive', label: 'Inactive' },
        { value: 'pending', label: 'Pending Review' },
      ],
    },
  }),
})

Search inside Select (searchable)

API: searchable, renderEmpty on Form.Field.Select (Chakra skin)

From the 10th option the open list of a Select shows a search field with autofocus — no prop needed.

<Form.Field.Select name="region" options={regions} />                          {/* 10+ options: search appears */}
<Form.Field.Select name="region" options={regions} searchable={false} />       {/* off */}
<Form.Field.Select name="region" options={few} searchable />                   {/* always */}
<Form.Field.Select
  name="region"
  options={regions}
  searchable={{ threshold: 20, placeholder: 'Find a region', emptyMessage: 'No such region' }}
/>
  • Matches the option text without case and diacritics, ё ≡ е, and also a query typed in the wrong keyboard layout ("ghbdtn" finds "Привет"). The selected value stays in the button while the list is filtered.
  • The first matching option is highlighted: Enter picks it, arrows move, Escape closes and clears the query.
  • With onCreate the query goes to the handler: onCreate('typed text'); the «+ Add "…"» item comes after the filter. renderEmpty({ search }) replaces the «nothing found» text.
  • ⚠️ Behaviour change: in a Select with 10+ options, typing after opening now filters the list instead of jumping by typeahead. Turn it off with searchable={false}. The shadcn skin (@letar/forms-shadcn 0.51.0+) searches the same way: with search on, the list is drawn on a Popover instead of Radix Select; arrows, Enter, Escape and F2 work.
  • Select or Combobox? All options are already on the client (enum, a dictionary up to hundreds of records) — Select. Options come from the server by the search text, or the catalog keeps growing — Combobox with useQuery.

Loading and the selected record (loading, useSelected)

  • Select — loading={query.isLoading}: spinner in the field, «Loading…» in the empty list and, while the selected value has no option yet, in the button instead of the placeholder.
  • Combobox — useSelected={(id) => useFindUniqueCategory({ where: { id } }, { enabled: !!id })} loads the record of the current value when it is not in the search page: the input shows its label without initialLabel, the pencil and F2 work, onUpdate receives data.
  • RelationFieldProvider: the whole record reaches option.data; RelationConfig.fieldProps are shared props for every field of the model (the field's own fieldProps win).

Data sources (loadOptions, loadSelected, @letar/forms-query)

Combobox takes exactly one source of options (the second one does not pass the types): static options (+ loading), the hook path useQuery (+ useSelected) or the promise path loadOptions (+ loadSelected) — a server action, fetch, an SDK, no TanStack Query needed. getLabel/getValue are required for both async paths.

<Form.Field.Combobox
  name="userId"
  loadOptions={(search, { signal }) => searchUsers({ search }, signal)}
  loadSelected={(id, { signal }) => getUser({ id }, signal)}
  getLabel={(u) => u.name}
  getValue={(u) => u.id}
  onLoadError={(error) => log(error)}
/>
  • The request starts after debounce once minChars is reached (minChars: 0 — with an empty string when the list is first opened; a list that was never opened sends nothing).
  • A new request cancels the previous one (signal); only the last result is applied; the previous results stay on screen with a spinner. An error shows «Failed to load» and «Retry» (Enter works too), no automatic retries.
  • After a confirmed onCreate/onUpdate the current search is requested again. For a cache and invalidation by key use useLoaderQuery from @letar/forms-query.

The @letar/forms-query package (both skins): fromSearchQuery, fromSelectedQuery, useQueryOptions, useLoaderQuery, useInvalidateAfter, /zenstack → useInvalidateModels. @letar/forms does not depend on it.

Combobox

API: Form.Field.Combobox

Searchable select with autocomplete. Great for large option lists. All options already on the client? A plain Select is enough: its search field appears by itself from the 10th option.

const Schema = z.object({
  country: z.string().meta({
    ui: { title: 'Country', placeholder: 'Search countries...' },
  }),
})

<Form.Field.Combobox
  name="country"
  options={countries.map(c => ({ value: c.code, label: c.name }))}
/>

Load options from a query hook as the user types via useQuery — signature matches TanStack Query ({ data, isLoading, error }):

<Form.Field.Combobox
  name="user"
  useQuery={(search) =>
    useFindManyUser({
      where: { name: { contains: search, mode: 'insensitive' } },
      take: 20,
    })
  }
  getLabel={(u) => u.name}
  getValue={(u) => u.id}
/>

With static options, the label for the current value is always available, so the field shows it as soon as it mounts. With useQuery, the item matching the current value may not be in the first (pre-search) result page — there's nothing to look the label up in yet. Pass initialLabel explicitly when editing an entity with a pre-selected value, otherwise the field shows an empty input even though the value is set:

<Form.Field.Combobox
  name="userId"
  useQuery={(search) =>
    useFindManyUser({
      where: { name: { contains: search, mode: 'insensitive' } },
      take: 20,
    })
  }
  getLabel={(u) => u.name}
  getValue={(u) => u.id}
  initialLabel={initialValues.userName}
/>

Creating a record from the field (onCreate)

API: onCreate and createLabel on Form.Field.Select and Form.Field.Combobox

When the needed dictionary record does not exist yet, the user creates it right from the field without leaving the form. The library does not render the creation dialog: your app opens its own dialog (or calls its own Server Action) and returns the created record.

<Form.Field.Select
  name="categoryId"
  options={categories}
  onCreate={async () => {
    const created = await openCategoryDialog()
    return created ? { label: created.name, value: created.id } : null
  }}
/>

<Form.Field.Combobox
  name="supplierId"
  options={suppliers}
  onCreate={async (name) => {
    const created = await openSupplierDialog({ name })
    return created ? { label: created.name, value: created.id } : null
  }}
/>
PropDescription
onCreate(search: string) => Promise<CreatedOption | null>, where CreatedOption is { label: string, value: string | number }. For Select search is empty, for Combobox it is the typed text
createLabelText of the item after "+ ". The Chakra skin defaults to the FormI18nProvider string ("Add…" / "Добавить…")

How the create item behaves

  • Select. The last list item is "+ Add…". Picking it calls onCreate('').
  • Combobox. While the search text is non-empty and no option label equals it exactly (case-insensitive), the list ends with a + Add "text" item. Picking it calls onCreate(text). Works with static options and with useQuery (Chakra skin).
  • Result. A returned { label, value } is added to the list and selected. A returned null (the user closed the dialog) changes nothing, and the Combobox keeps the search text.
  • Service value. The value of the create item never reaches the form data.
  • Errors. An exception thrown by onCreate is not swallowed: it surfaces as an unhandled rejection. Catch it inside the callback and show your own message.

Lifetime of the created record. It lives while the field is mounted. If the app's own options later contain a record with the same value (for example after the dictionary was revalidated), the app's option wins and there is no duplicate.

createForm wrappers. For extraSelects, lazySelects, extraComboboxes and lazyComboboxes the app's wrapper component must forward onCreate to the inner field (spread the props), otherwise the create item does not appear.

Skins. Implemented in Chakra (@letar/forms) and shadcn (@letar/forms-shadcn, static options only; the default item text is "Добавить…"). Not implemented yet in @letar/forms-vue, @letar/forms-vue-shadcn and @letar/forms-angular.

Custom option rendering (renderOption, renderValue, textValue, data)

API: renderOption, renderValue, textValue, data on Form.Field.Select; renderOption, getTextValue, textValue, data on Form.Field.Combobox. Chakra and shadcn skins.

An option carries typed app data (data) and can be drawn as any node. TData is inferred from options (for Combobox with useQuery — from the loaded items) and flows into the render functions.

<Form.Field.Select
  name="cityId"
  options={cities.map((c) => ({ value: c.id, label: c.name, data: c }))}
  renderOption={(o, { selected }) => <span>{o.label} <small>{o.data?.region}</small></span>}
  renderValue={(o) => <b>{o.data?.name}</b>}
/>
Prop / option fieldWhereDescription
dataoptionApp data, never read by the field
textValueoptionString form for search, typeahead and the trigger caption. Needed when label is not a string
renderOptionSelect, Combobox(option, { selected, disabled }) => ReactNode; the skin keeps its item frame
renderValueSelect only(option) => ReactNode for the trigger; null/'' falls back to the option text
getTextValueCombobox only(item) => string for useQuery items
  • A non-string label is no longer flattened to a string: the item draws the node. Without textValue search, typeahead and the trigger caption use value; in development the field warns once.
  • renderValue lives inside a <button> — phrasing content only. While nothing is selected the placeholder shows.
  • The service "+ Add…" item skips renderOption; onCreate may return data.
  • Combobox has no renderValue: the input holds plain text.
  • ⚠️ Behavior change: options with a node label used to show their value, now they show the node.

Editing a record from the field (onUpdate)

API: onUpdate, createItem, listFooter, Form.Field.Select.EditButton, Form.Field.Select.CreateButton on Form.Field.Select; the same plus renderEmpty and getEditable on Form.Field.Combobox. Chakra and shadcn skins.

A pencil sits at every item and at the selected value. The app opens its own dialog (its server action) and returns { label, value, data? } — or null if the user cancelled.

<Form.Field.Select
  name="workTypeId"
  options={workTypes.map((w) => ({ value: w.id, label: w.name, data: w, editable: !w.system }))}
  onUpdate={async (option) => {
    const saved = await openWorkTypeDialog(option.data)
    return saved ? { label: saved.name, value: saved.id, data: saved } : null
  }}
/>
  • Same value = caption fix (the form stays clean). Another value = the record was replaced; the selected one follows it.
  • The edit lies over the app's options until the app refetches the list.
  • The list closes before the app's dialog; while the action runs the pencils are disabled.
  • F2 (Fn+F2 on laptops) edits the highlighted item or the selected value. The shadcn Combobox has no keyboard list navigation, so no F2 there.
  • editable: false on an option hides its pencil. With your own renderOption the default pencil is not drawn — put <Form.Field.Select.EditButton /> inside. Slots inside renderValue draw nothing (a <button> cannot nest a button).
  • createItem={false} removes the service "+ Add…" item: use <Form.Field.Select.CreateButton /> in listFooter. Both slots accept asChild.

Optimistic mode (ctx.optimistic)

onCreate(search, ctx) and onUpdate(option, ctx) get ctx.optimistic(preview) as the second argument. Call it when the app dialog is closed and the request has gone to the server: the field shows and selects the result at once instead of waiting.

<Form.Field.Select
  name="categoryId"
  {...categories.fieldProps}
  onCreate={async (search, { optimistic }) => {
    const input = await dialog.open({ name: search })
    if (!input) { return null }
    optimistic({ label: input.name }) // the record is visible and selected right away
    const created = await create.mutateAsync({ data: input }) // the server's answer, not the input
    return { label: created.name, value: created.id, data: created }
  }}
  onSettleError={(info) => toast.error(`Could not save “${info.preview.label}”`)}
/>
  • While the server is silent the record is dimmed with a spinner (aria-busy); the temporary id never reaches the form value. A resolve replaces it with the real record; null, a rejection or settleTimeout (30 s) roll it back.
  • Without onSettleError the field shows its own message under itself (role="status").
  • The form's submit waits for pending actions: the submit button shows a spinner, onSubmit gets the real value, a refusal cancels the submit.
  • An option with pending: true (or getPending on Combobox) is dimmed, not selectable, without a pencil. For ZenStack optimisticUpdate: true use useZenStackOptions from @letar/forms-query/zenstack.

RadioGroup

API: Form.Field.RadioGroup

Radio button group for single selection.

const Schema = z.object({
  plan: z.enum(['free', 'pro', 'enterprise']).meta({
    ui: { title: 'Plan' },
  }),
})

<Form.Field.RadioGroup name="plan" />

Checkbox

API: Form.Field.Checkbox

Standard checkbox for boolean values.

const Schema = z.object({
  agree: z.boolean().meta({
    ui: { title: 'I agree to the terms and conditions' },
  }),
})

<Form.Field.Checkbox name="agree" />

Switch

API: Form.Field.Switch

Toggle switch — visually distinct from checkbox.

const Schema = z.object({
  notifications: z.boolean().meta({
    ui: { title: 'Enable notifications' },
  }),
})

<Form.Field.Switch name="notifications" />

Tags

API: Form.Field.Tags

Tag input with autocomplete. Users can add multiple tags.

const Schema = z.object({
  skills: z.array(z.string()).meta({
    ui: { title: 'Skills', placeholder: 'Add a skill...' },
  }),
})

<Form.Field.Tags name="skills" />

// With max limit
<Form.Field.Tags name="skills" maxTags={5} />

RadioCard

API: Form.Field.RadioCard

Card-based selection — visually richer than RadioGroup. Supports description per option.

const options = [
  { value: 'starter', label: 'Starter', description: '$0/mo — for side projects' },
  { value: 'pro', label: 'Pro', description: '$29/mo — for teams' },
  { value: 'enterprise', label: 'Enterprise', description: '$99/mo — unlimited' },
]

<Form.Field.RadioCard name="plan" options={options} />

CheckboxCard

API: Form.Field.CheckboxCard

Card-based multiple selection. Like RadioCard but allows selecting multiple items.

<Form.Field.CheckboxCard
  name="features"
  options={[
    { value: 'auth', label: 'Authentication', description: 'OAuth, magic links' },
    { value: 'payments', label: 'Payments', description: 'Stripe integration' },
    { value: 'analytics', label: 'Analytics', description: 'Usage tracking' },
  ]}
/>

Listbox

API: Form.Field.Listbox

Dropdown list for single or multiple selection. Alternative to Select for larger lists.

// Single selection
<Form.Field.Listbox name="timezone" options={timezoneOptions} />

// Multiple selection
<Form.Field.Listbox name="permissions" options={permissionOptions} selectionMode="multiple" />

// Second line under the label — `description` on an option (Chakra and shadcn skins)
<Form.Field.Listbox name="plan" options={[{ value: 'pro', label: 'Pro', description: 'For teams' }]} />

NativeSelect

API: Form.Field.NativeSelect

Standard HTML <select> element. Lighter than Select, uses native browser UI.

<Form.Field.NativeSelect name="country" options={countryOptions} />

SegmentedGroup

API: Form.Field.SegmentedGroup

Segmented control — pill-shaped toggle group for small option sets.

<Form.Field.SegmentedGroup
  name="view"
  options={[
    { value: 'grid', label: 'Grid' },
    { value: 'list', label: 'List' },
    { value: 'table', label: 'Table' },
  ]}
/>

On this page