@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' },
      ],
    },
  }),
})

Combobox

API: Form.Field.Combobox

Searchable select with autocomplete. Great for large option lists.

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}
/>

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" />

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