Поля выбора
Компоненты выбора — Select, Combobox, RadioGroup, Checkbox, Switch
Полный пример
Полный sandbox-пример (все select-подобные поля на одной странице), прочитанный напрямую из
исходников form-develop-app / form-develop-app-shadcn на сборке — переключи скин, чтобы
увидеть те же поля в обеих реализациях.
'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>
)
}'use client'
import { FieldCascadingSelect, FieldCombobox, FieldNativeSelect, FieldSelect } from '@letar/forms-shadcn'
import { useState } from 'react'
import { DemoForm, DemoPageLayout, SubmittedDataPreview } from '../_components'
const frameworkOptions = [
{ label: 'React', value: 'react' },
{ label: 'Vue', value: 'vue' },
{ label: 'Svelte', value: 'svelte' },
]
const shippingCountryOptions = [
{ label: 'Россия', value: 'ru' },
{ label: 'Казахстан', value: 'kz' },
]
const CITIES_BY_COUNTRY: Record<string, { label: string; value: string }[]> = {
ru: [{ label: 'Москва', value: 'msk' }, { label: 'Казань', value: 'kzn' }],
kz: [{ label: 'Алматы', value: 'alm' }, { label: 'Астана', value: 'ast' }],
}
/** Имитация окна создания записи в приложении: задержка вместо диалога и серверного действия */
function fakeCreateDialog(name: string): Promise<{ label: string; value: string }> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ label: name.trim() || 'Новая запись', value: `new-${Math.random().toString(36).slice(2, 8)}` })
}, 400)
})
}
interface SelectValues {
framework: string
country: string
frameworkSearch: string
shippingCountry: string
shippingCity: string
createSelect: string
createCombobox: string
}
const defaultValues: SelectValues = {
framework: '',
country: '',
frameworkSearch: '',
shippingCountry: '',
shippingCity: '',
createSelect: '',
createCombobox: '',
}
export default function SelectDemoPage() {
const [submitted, setSubmitted] = useState<SelectValues | null>(null)
return (
<DemoPageLayout
title="Select-поля"
description="Select, NativeSelect, Combobox, CascadingSelect (зависимый select)"
>
<DemoForm<SelectValues> defaultValues={defaultValues} onSubmit={setSubmitted}>
<FieldSelect name="framework" label="Фреймворк" options={frameworkOptions} placeholder="Выберите" />
<FieldNativeSelect
name="country"
label="Страна"
options={[
{ label: 'Россия', value: 'ru' },
{ label: 'Казахстан', value: 'kz' },
]}
/>
<FieldCombobox name="frameworkSearch" label="Поиск фреймворка" options={frameworkOptions} />
<FieldSelect
name="shippingCountry"
label="Страна доставки"
options={shippingCountryOptions}
placeholder="Выберите"
/>
<FieldCascadingSelect
name="shippingCity"
label="Город доставки"
dependsOn="shippingCountry"
loadOptions={async (country) => CITIES_BY_COUNTRY[country ?? ''] ?? []}
placeholderWhenDisabled="Сначала выберите страну"
/>
{/* onCreate: создание записи справочника из поля (только статические опции) */}
<FieldSelect
name="createSelect"
label="Фреймворк (с пунктом «Добавить…»)"
options={frameworkOptions}
placeholder="Выберите"
createLabel="Добавить фреймворк…"
onCreate={async () => fakeCreateDialog('Новый фреймворк')}
/>
<FieldCombobox
name="createCombobox"
label="Поиск фреймворка (с созданием)"
options={frameworkOptions}
onCreate={async (text) => {
// Путь «пользователь закрыл окно»: null — ничего не меняется
if (text.trim().toLowerCase().startsWith('отмена')) {
return null
}
return fakeCreateDialog(text)
}}
/>
<button
type="submit"
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
>
Отправить
</button>
</DemoForm>
<SubmittedDataPreview data={submitted} />
</DemoPageLayout>
)
}import { AppForm } from '@letar/forms-vue'
import { defineComponent, h, ref } from 'vue'
import { z } from 'zod'
import { FieldSelect } from '../../src/index'
/**
* Изолированный пример FieldSelect — самодостаточный файл (своя Zod-схема, свой AppForm).
*/
const schema = z.object({
category: z.string().meta({ ui: { title: 'Категория' } }),
})
const CATEGORY_OPTIONS = [
{ value: 'furniture', label: 'Мебель' },
{ value: 'electronics', label: 'Электроника' },
{ value: 'books', label: 'Книги' },
]
export const SelectDemo = defineComponent({
name: 'SelectDemo',
setup() {
const submitted = ref<Record<string, unknown> | null>(null)
return () =>
h('div', { class: 'space-y-6' }, [
h(
AppForm,
{
schema,
initialValue: { category: '' },
onSubmit: (value: Record<string, unknown>) => {
submitted.value = value
},
},
{
default: () => [
h(FieldSelect, { name: 'category', options: CATEGORY_OPTIONS }),
h(
'button',
{ type: 'submit', class: 'bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm' },
'Сохранить',
),
],
},
),
submitted.value
? h('pre', { class: 'bg-muted mt-4 rounded-md p-3 text-xs' }, JSON.stringify(submitted.value, null, 2))
: null,
])
},
})Angular-пример для этого поля появится позже.
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-shadcn0.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 — не строка |
renderOption | Select, 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) приглушена, не выбирается, без карандаша. ДляoptimisticUpdateZenStack —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: 'Список' },
]}
/>