@letar/forms

Поля выбора

Компоненты выбора — Select, Combobox, RadioGroup, Checkbox, Switch

Полный пример

Полный sandbox-пример (все select-подобные поля на одной странице), прочитанный напрямую из исходников form-develop-app / form-develop-app-shadcn на сборке — переключи скин, чтобы увидеть те же поля в обеих реализациях.

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

Выпадающий список для выбора одного варианта.

const Schema = z.object({
  role: z.enum(['user', 'admin', 'moderator']).meta({
    ui: { title: 'Роль' },
  }),
})

const options = [
  { value: 'user', label: 'Пользователь' },
  { value: 'admin', label: 'Администратор' },
  { value: 'moderator', label: 'Модератор' },
]

<Form.Field.Select name="role" options={options} />

Поиск внутри Select (searchable)

API: searchable, renderEmpty у Form.Field.Select (Chakra-скин)

С 10-й опции в открытом списке Select появляется поле поиска с автофокусом — проп не нужен.

<Form.Field.Select name="region" options={regions} />                          {/* 10+ опций: поиск появится сам */}
<Form.Field.Select name="region" options={regions} searchable={false} />       {/* выключен */}
<Form.Field.Select name="region" options={few} searchable />                   {/* всегда */}
<Form.Field.Select
  name="region"
  options={regions}
  searchable={{ threshold: 20, placeholder: 'Найти регион', emptyMessage: 'Такого региона нет' }}
/>
  • Ищет по тексту опции без регистра и диакритики, ё ≡ е, и по запросу, набранному не в той раскладке («ghbdtn» находит «Привет»). Выбранное значение остаётся в кнопке, пока список отфильтрован.
  • Первая подходящая опция подсвечена: Enter выбирает её, стрелки ходят по списку, Escape закрывает и сбрасывает запрос.
  • С onCreate текст поиска попадает в обработчик: onCreate('введённый текст'); пункт «+ Добавить "…"» идёт после фильтра. renderEmpty({ search }) заменяет текст «Ничего не найдено».
  • ⚠️ Изменение поведения: у Select с 10+ опциями печать после открытия теперь фильтрует список, а не прыгает typeahead-ом. Выключается searchable={false}. В shadcn-скине (с @letar/forms-shadcn 0.51.0) поиск тот же: при включённом поиске список рисуется на Popover, а не на Radix Select; стрелки, Enter, Escape и F2 работают.
  • Select или Combobox? Все варианты уже на клиенте (enum, справочник до сотен записей) — Select. Варианты приходят с сервера по тексту поиска или каталог растёт — Combobox с useQuery.

Загрузка и выбранная запись (loading, useSelected)

  • Select — loading={query.isLoading}: спиннер в поле, «Загрузка…» в пустом списке и, пока у выбранного значения нет опции, в кнопке вместо placeholder.
  • Combobox — useSelected={(id) => useFindUniqueCategory({ where: { id } }, { enabled: !!id })} догружает запись текущего значения, если её нет в выдаче поиска: инпут показывает подпись без initialLabel, карандаш и F2 работают, onUpdate получает data.
  • RelationFieldProvider: запись целиком лежит в option.data; RelationConfig.fieldProps — общие пропсы всех полей модели (собственные fieldProps поля сильнее).

Источники данных (loadOptions, loadSelected, @letar/forms-query)

У Combobox ровно один источник опций (второй рядом не проходит по типам): статичные options (+ loading), хук useQuery (+ useSelected) или промис loadOptions (+ loadSelected) — server action, fetch, SDK, TanStack Query не нужен. getLabel/getValue обязательны для обоих асинхронных путей.

<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)}
/>
  • Запрос уходит через debounce, когда набрано minChars (minChars: 0 — с пустой строкой при первом открытии списка; список ни разу не открывали — запросов нет).
  • Новый запрос отменяет прошлый (signal), применяется только последний; прошлая выдача остаётся на экране со спиннером. Ошибка — «Не удалось загрузить» и «Повторить» (Enter тоже), автоповторов нет.
  • После подтверждённого onCreate/onUpdate текущий поиск запрашивается заново. Нужен кэш и инвалидация по ключу — useLoaderQuery из @letar/forms-query.

Пакет @letar/forms-query (оба скина): fromSearchQuery, fromSelectedQuery, useQueryOptions, useLoaderQuery, useInvalidateAfter, /zenstack → useInvalidateModels. @letar/forms от него не зависит.

Combobox

API: Form.Field.Combobox

Выпадающий список с поиском. Поддерживает асинхронную загрузку опций.

<Form.Field.Combobox name="city" options={cityOptions} placeholder="Начните вводить город..." />

Асинхронный поиск

Загрузка опций из query-хука по мере ввода через useQuery — сигнатура совпадает с 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}
/>

Редактирование существующего значения при асинхронном поиске

Для статических options label текущего значения всегда доступен, поэтому поле показывает его сразу при монтировании. С useQuery элемент, соответствующий текущему значению, может отсутствовать в первой (до-поисковой) странице результатов — искать label ещё не в чем. Передавай initialLabel явно при редактировании сущности с уже выбранным значением — иначе поле покажет пустой инпут, хотя значение выставлено верно:

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

Создание записи из поля (onCreate)

API: onCreate и createLabel у Form.Field.Select и Form.Field.Combobox

Когда нужной записи справочника ещё нет, пользователь создаёт её прямо из поля, не покидая форму. Библиотека не рисует окно создания: приложение открывает своё окно (или вызывает свой Server Action) и возвращает созданную запись.

<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
  }}
/>
ПропОписание
onCreate(search: string) => Promise<CreatedOption | null>, где CreatedOption — { label: string, value: string | number }. У Select search пустая, у Combobox — введённый текст
createLabelТекст пункта после «+ ». В Chakra-скине по умолчанию берётся из FormI18nProvider («Добавить…» / «Add…»)

Как ведёт себя пункт создания

  • Select. Последним пунктом списка стоит «+ Добавить…». Выбор пункта вызывает onCreate('').
  • Combobox. Пока в поиске есть текст и ни одна подпись не совпала с ним точно (без учёта регистра), список заканчивается пунктом «+ Добавить "текст"». Выбор пункта вызывает onCreate(текст). Работает и со статическими options, и с useQuery (в Chakra-скине).
  • Результат. Вернулась запись { label, value } — она добавляется в список и выбирается. Вернулся null (пользователь закрыл окно) — ничего не меняется, а Combobox сохраняет введённый текст поиска.
  • Служебное значение. Значение пункта создания в данные формы не попадает никогда.
  • Ошибки. Исключение из onCreate не проглатывается: оно всплывает как необработанный reject. Ловите его в самом колбэке и показывайте своё сообщение.

Время жизни созданной записи. Она живёт, пока поле смонтировано. Если позже список options приложения (например, после перезагрузки справочника) уже содержит запись с тем же value, побеждает запись приложения — дубля не будет.

Обёртки createForm. Для extraSelects, lazySelects, extraComboboxes и lazyComboboxes компонент-обёртка приложения должен передавать onCreate внутрь поля (через spread пропсов), иначе пункт создания не появится.

Скины. Реализовано в Chakra (@letar/forms) и shadcn (@letar/forms-shadcn, только статические options; текст пункта по умолчанию «Добавить…»). В @letar/forms-vue, @letar/forms-vue-shadcn и @letar/forms-angular пока не реализовано.

Свой рендер опций (renderOption, renderValue, textValue, data)

API: renderOption, renderValue, textValue, data у Form.Field.Select; renderOption, getTextValue, textValue, data у Form.Field.Combobox. Chakra- и shadcn-скины.

Опция несёт типизированные данные приложения (data) и может рисоваться любым узлом. TData выводится из options (у Combobox с useQuery — из загруженных элементов) и попадает в render-функции.

<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>}
/>
Проп / поле опцииГдеОписание
dataопцияДанные приложения, поле их не читает
textValueопцияСтроковая форма для поиска, typeahead и подписи в триггере. Нужна, если label — не строка
renderOptionSelect, Combobox(option, { selected, disabled }) => ReactNode; рамку пункта рисует скин
renderValueтолько Select(option) => ReactNode для триггера; null/'' — откат к тексту опции
getTextValueтолько Combobox(item) => string для элементов useQuery
  • Нестроковый label больше не сплющивается в строку: пункт рисует узел. Без textValue поиск, typeahead и подпись в триггере идут по value; в dev-режиме поле один раз предупредит.
  • renderValue живёт внутри <button> — только фразовое содержимое. Пока ничего не выбрано, виден placeholder.
  • Служебный пункт «+ Добавить…» через renderOption не проходит; onCreate может вернуть data.
  • У Combobox renderValue нет: в инпуте обычный текст.
  • ⚠️ Изменение поведения: опции с label-узлом раньше показывали value, теперь — узел.

Правка записи из поля (onUpdate)

API: onUpdate, createItem, listFooter, Form.Field.Select.EditButton, Form.Field.Select.CreateButton у Form.Field.Select; то же плюс renderEmpty и getEditable у Form.Field.Combobox. Chakra- и shadcn-скины.

Карандаш есть у каждого пункта и у выбранного значения. Приложение открывает своё окно (его server action) и возвращает { label, value, data? } — либо null, если пользователь отказался.

<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
  }}
/>
  • Тот же value — правка подписи (форма не dirty). Другой value — запись заменена, выбранное переезжает на новую.
  • Правка лежит поверх options приложения, пока оно не перезапросит список.
  • Список закрывается до окна приложения; пока действие идёт, карандаши disabled.
  • F2 (на ноутбуках Fn+F2) правит подсвеченный пункт или выбранное значение. У Combobox в shadcn нет клавиатурной навигации по списку — там F2 не работает.
  • editable: false у опции прячет её карандаш. При своём renderOption карандаш по умолчанию не рисуется — поставь внутрь <Form.Field.Select.EditButton />. Внутри renderValue слоты не рисуются (в <button> нельзя вложить кнопку).
  • createItem={false} убирает служебный пункт «+ Добавить…»: поставь <Form.Field.Select.CreateButton /> в listFooter. Оба слота принимают asChild.

Оптимистичный режим (ctx.optimistic)

onCreate(search, ctx) и onUpdate(option, ctx) получают вторым аргументом ctx.optimistic(preview). Вызовите его, когда окно приложения закрыто и запрос ушёл на сервер: поле сразу показывает и выбирает результат, не дожидаясь ответа.

<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 }) // запись видна и выбрана сразу
    const created = await create.mutateAsync({ data: input }) // ответ сервера, а не input
    return { label: created.name, value: created.id, data: created }
  }}
  onSettleError={(info) => toast.error(`Не удалось сохранить «${info.preview.label}»`)}
/>
  • Пока сервер молчит, запись приглушена и со спиннером (aria-busy); временный id в значение формы не попадает. Резолв заменяет её настоящей записью; null, reject или settleTimeout (30 с) — откат.
  • Без onSettleError поле показывает своё сообщение под собой (role="status").
  • Отправка формы ждёт ожидающие действия: кнопка показывает загрузку, onSubmit получает настоящее значение, отказ отменяет отправку.
  • Опция с pending: true (или getPending у Combobox) приглушена, не выбирается, без карандаша. Для optimisticUpdate ZenStack — useZenStackOptions из @letar/forms-query/zenstack.

RadioGroup

API: Form.Field.RadioGroup

Группа радиокнопок для выбора одного варианта.

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

// Горизонтальная ориентация
<Form.Field.RadioGroup name="plan" options={planOptions} orientation="horizontal" />

RadioCard

API: Form.Field.RadioCard

Карточки для выбора одного варианта — визуально богаче, чем RadioGroup.

const options = [
  { value: 'starter', label: 'Starter', description: '$0/мес' },
  { value: 'pro', label: 'Pro', description: '$29/мес' },
]

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

Checkbox

API: Form.Field.Checkbox

Одиночный чекбокс (да/нет).

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

Switch

API: Form.Field.Switch

Переключатель (вкл/выкл).

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

Tags

API: Form.Field.Tags

Ввод тегов — множественные текстовые значения.

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

// С ограничением количества
<Form.Field.Tags name="skills" maxTags={5} />

CheckboxCard

API: Form.Field.CheckboxCard

Карточки для множественного выбора.

<Form.Field.CheckboxCard
  name="features"
  options={[
    { value: 'auth', label: 'Аутентификация', description: 'OAuth, magic links' },
    { value: 'payments', label: 'Платежи', description: 'Интеграция Stripe' },
  ]}
/>

Listbox

API: Form.Field.Listbox

Выпадающий список для единичного или множественного выбора.

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

// Вторая строка под подписью — `description` у опции (Chakra- и shadcn-скины)
<Form.Field.Listbox name="plan" options={[{ value: 'pro', label: 'Pro', description: 'Для команд' }]} />

NativeSelect

API: Form.Field.NativeSelect

Стандартный HTML <select>. Легче, чем Select, использует нативный UI браузера.

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

SegmentedGroup

API: Form.Field.SegmentedGroup

Сегментированный контрол — переключатель для небольших наборов опций.

<Form.Field.SegmentedGroup
  name="view"
  options={[
    { value: 'grid', label: 'Сетка' },
    { value: 'list', label: 'Список' },
  ]}
/>

On this page