@reformer/ui-kit 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/dist/components/form-array/form-array-section.d.ts +60 -0
  4. package/dist/components/form-array/index.d.ts +2 -0
  5. package/dist/components/form-wizard/form-wizard-actions.d.ts +11 -0
  6. package/dist/components/form-wizard/form-wizard-progress.d.ts +7 -0
  7. package/dist/components/form-wizard/form-wizard.d.ts +40 -0
  8. package/dist/components/form-wizard/index.d.ts +8 -0
  9. package/dist/components/form-wizard/step-indicator.d.ts +10 -0
  10. package/dist/components/state/error-state.d.ts +10 -0
  11. package/dist/components/state/index.d.ts +2 -0
  12. package/dist/components/state/loading-state.d.ts +8 -0
  13. package/dist/components/ui/async-boundary.d.ts +63 -0
  14. package/dist/components/ui/box.d.ts +41 -0
  15. package/dist/components/ui/button.d.ts +35 -0
  16. package/dist/components/ui/checkbox.d.ts +48 -0
  17. package/dist/components/ui/collapsible.d.ts +59 -0
  18. package/dist/components/ui/example-card.d.ts +57 -0
  19. package/dist/components/ui/form-field.d.ts +72 -0
  20. package/dist/components/ui/input-mask.d.ts +52 -0
  21. package/dist/components/ui/input-password.d.ts +51 -0
  22. package/dist/components/ui/input.d.ts +54 -0
  23. package/dist/components/ui/radio-group.d.ts +60 -0
  24. package/dist/components/ui/section.d.ts +52 -0
  25. package/dist/components/ui/select.d.ts +316 -0
  26. package/dist/components/ui/textarea.d.ts +53 -0
  27. package/dist/form-array-section-CItAR061.js +92 -0
  28. package/dist/form-array.d.ts +2 -0
  29. package/dist/form-array.js +4 -0
  30. package/dist/form-wizard-C-yRYqTI.js +118 -0
  31. package/dist/form-wizard.d.ts +2 -0
  32. package/dist/form-wizard.js +7 -0
  33. package/dist/index.d.ts +37 -0
  34. package/dist/index.js +170 -0
  35. package/dist/lib/utils.d.ts +29 -0
  36. package/dist/loading-state-4VeOE6iN.js +38 -0
  37. package/dist/state.d.ts +2 -0
  38. package/dist/state.js +5 -0
  39. package/dist/ui/async-boundary.d.ts +2 -0
  40. package/dist/ui/async-boundary.js +12 -0
  41. package/dist/ui/box.d.ts +2 -0
  42. package/dist/ui/box.js +7 -0
  43. package/dist/ui/button.d.ts +2 -0
  44. package/dist/ui/button.js +78 -0
  45. package/dist/ui/checkbox.d.ts +2 -0
  46. package/dist/ui/checkbox.js +35 -0
  47. package/dist/ui/collapsible.d.ts +2 -0
  48. package/dist/ui/collapsible.js +31 -0
  49. package/dist/ui/form-field.d.ts +2 -0
  50. package/dist/ui/form-field.js +217 -0
  51. package/dist/ui/input-mask.d.ts +2 -0
  52. package/dist/ui/input-mask.js +36 -0
  53. package/dist/ui/input-password.d.ts +2 -0
  54. package/dist/ui/input-password.js +72 -0
  55. package/dist/ui/input.d.ts +2 -0
  56. package/dist/ui/input.js +48 -0
  57. package/dist/ui/radio-group.d.ts +2 -0
  58. package/dist/ui/radio-group.js +53 -0
  59. package/dist/ui/section.d.ts +2 -0
  60. package/dist/ui/section.js +16 -0
  61. package/dist/ui/select.d.ts +2 -0
  62. package/dist/ui/select.js +241 -0
  63. package/dist/ui/textarea.d.ts +2 -0
  64. package/dist/ui/textarea.js +38 -0
  65. package/dist/utils-DtaLkIY8.js +2776 -0
  66. package/llms.txt +3461 -0
  67. package/package.json +161 -0
package/llms.txt ADDED
@@ -0,0 +1,3461 @@
1
+ # ReFormer Ui Kit - LLM Integration Guide
2
+ # AUTO-GENERATED. Edit docs/llms/*.md or JSDoc in src/ and run npm run generate:llms.
3
+
4
+ > Styled form components with Tailwind CSS and Radix UI for @reformer ecosystem
5
+ > Package: @reformer/ui-kit • Version: 1.0.0-beta.2
6
+
7
+ ## Table of Contents
8
+ - 01-overview.md — Overview
9
+ - 02-text-fields.md — Text fields
10
+ - 03-choice-fields.md — Choice fields
11
+ - 04-layout-and-buttons.md — Layout and buttons
12
+ - 05-form-field-integration.md — FormField integration
13
+ - 06-troubleshooting.md — Troubleshooting / FAQ
14
+ - 07-form-wizard.md — FormWizard — multi-step форма
15
+ - 08-form-array-section.md — FormArraySection — UI для FormArray
16
+ - API Reference (auto-generated from JSDoc)
17
+
18
+ ## 1. Installation
19
+
20
+ ```bash
21
+ npm install @reformer/ui-kit @reformer/cdk @reformer/core
22
+ ```
23
+
24
+ Peer-зависимости (должны быть в проекте):
25
+
26
+ ```json
27
+ {
28
+ "@reformer/cdk": ">=1.0.0-beta.0",
29
+ "@reformer/core": ">=1.1.0-beta.0",
30
+ "@radix-ui/react-select": "^2.0.0",
31
+ "@radix-ui/react-slot": "^1.0.0",
32
+ "lucide-react": "^0.400.0",
33
+ "react": "^18.0.0 || ^19.0.0",
34
+ "react-dom": "^18.0.0 || ^19.0.0"
35
+ }
36
+ ```
37
+
38
+ Tailwind должен быть подключён в проекте: `@reformer/ui-kit` использует
39
+ utility-классы (`h-9`, `rounded-md`, `border-input`, `text-destructive`, ...).
40
+ Для тем (variables `--primary`, `--destructive`, `--ring`, ...) используйте
41
+ конфигурацию shadcn/ui или собственную аналогичную.
42
+
43
+ ## 2. Import Patterns
44
+
45
+ ```typescript
46
+ // Все компоненты из корня (рекомендованный способ для приложений)
47
+ import {
48
+ Input,
49
+ InputMask,
50
+ InputPassword,
51
+ Textarea,
52
+ Checkbox,
53
+ RadioGroup,
54
+ Select,
55
+ SelectGroup,
56
+ SelectItem,
57
+ SelectLabel,
58
+ SelectTrigger,
59
+ SelectValue,
60
+ Button,
61
+ FormField,
62
+ AsyncBoundary,
63
+ ExampleCard,
64
+ Box,
65
+ Section,
66
+ Collapsible,
67
+ cn,
68
+ } from '@reformer/ui-kit';
69
+ ```
70
+
71
+ Для tree-shaking и оптимизации бандла доступен импорт из подмодулей:
72
+
73
+ ```typescript
74
+ // Tree-shaking (для библиотек / тонких бандлов)
75
+ import { Input } from '@reformer/ui-kit/input';
76
+ import { Select } from '@reformer/ui-kit/select';
77
+ import { FormField } from '@reformer/ui-kit/form-field';
78
+ import { Button } from '@reformer/ui-kit/button';
79
+ ```
80
+
81
+ Реэкспорт внутри одного модуля (`@reformer/ui-kit/select` отдаёт `Select` и все
82
+ 8 sub-компонентов) также работает.
83
+
84
+ ## 3. Quick Start
85
+
86
+ Минимальная форма из двух полей с валидацией и сабмитом. `FormField`
87
+ самостоятельно подцепляет `value`/`error`/`pending` через `@reformer/cdk`:
88
+
89
+ ```tsx
90
+ import { useMemo } from 'react';
91
+ import { createForm, type FormSchema } from '@reformer/core';
92
+ import { Button, FormField, Input } from '@reformer/ui-kit';
93
+ import { object, string, email, minLength } from 'valibot';
94
+
95
+ interface RegistrationForm {
96
+ email: string;
97
+ password: string;
98
+ }
99
+
100
+ function RegistrationPage() {
101
+ const form = useMemo(
102
+ () =>
103
+ createForm<FormSchema<RegistrationForm>>({
104
+ email: { component: Input, validations: [string(), email()] },
105
+ password: { component: Input, validations: [string(), minLength(8)] },
106
+ }),
107
+ []
108
+ );
109
+
110
+ const onSubmit = async () => {
111
+ form.markAsTouched();
112
+ if (!(await form.validate())) return;
113
+ console.log('values', form.getValue());
114
+ };
115
+
116
+ return (
117
+ <form
118
+ onSubmit={(e) => {
119
+ e.preventDefault();
120
+ onSubmit();
121
+ }}
122
+ className="space-y-4 max-w-md"
123
+ >
124
+ <FormField control={form.email} testId="email" />
125
+ <FormField control={form.password} testId="password" />
126
+ <Button type="submit">Зарегистрироваться</Button>
127
+ </form>
128
+ );
129
+ }
130
+ ```
131
+
132
+ ## 4. Components
133
+
134
+ | Name | Purpose | Where documented |
135
+ | ------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------- |
136
+ | `Input` | Текстовое поле (`text`/`email`/`number`/`tel`/`url`). | [02-text-fields.md](02-text-fields.md) |
137
+ | `InputMask` | Поле ввода со строковой маской (телефон, дата, ИНН). | [02-text-fields.md](02-text-fields.md) |
138
+ | `InputPassword` | Поле пароля с переключателем видимости. | [02-text-fields.md](02-text-fields.md) |
139
+ | `Textarea` | Многострочное поле. | [02-text-fields.md](02-text-fields.md) |
140
+ | `Checkbox` | Чекбокс с label рядом с контролом. | [03-choice-fields.md](03-choice-fields.md) |
141
+ | `RadioGroup` | Группа радио-кнопок из массива `options`. | [03-choice-fields.md](03-choice-fields.md) |
142
+ | `Select` (+ 8 sub-компонентов) | Выпадающий список с inline `options` или async `resource`. | [03-choice-fields.md](03-choice-fields.md) |
143
+ | `Button` | Кнопка с вариантами (`variant`, `size`, `asChild`). | [04-layout-and-buttons.md](04-layout-and-buttons.md) |
144
+ | `AsyncBoundary` | Контейнер с состояниями `loading`/`error`/`ready`. | [04-layout-and-buttons.md](04-layout-and-buttons.md) |
145
+ | `ExampleCard` | Карточка-обёртка для демо в playground. | [04-layout-and-buttons.md](04-layout-and-buttons.md) |
146
+ | `cn` | Утилита для конкатенации Tailwind-классов. | [04-layout-and-buttons.md](04-layout-and-buttons.md) |
147
+ | `FormField` | Wrapper «label + control + error + pending» поверх CDK. | [05-form-field-integration.md](05-form-field-integration.md) |
148
+ | `Box`, `Section`, `Collapsible` | Контейнеры для `RenderSchema` (см. рендерер). | [renderer-react](../../../reformer-renderer-react/docs/llms/) |
149
+
150
+ Полный troubleshooting (number-input возвращает строку, Select не показывает
151
+ options, mask пропускает символы, forwardRef + Slot конфликты, и т.п.) —
152
+ [06-troubleshooting.md](06-troubleshooting.md).
153
+
154
+ ## 5. See also
155
+
156
+ - [02-text-fields.md](02-text-fields.md) — `Input`, `InputMask`, `InputPassword`, `Textarea`.
157
+ - [03-choice-fields.md](03-choice-fields.md) — `Checkbox`, `RadioGroup`, `Select`.
158
+ - [04-layout-and-buttons.md](04-layout-and-buttons.md) — `Button`, `AsyncBoundary`, `ExampleCard`, `cn`.
159
+ - [05-form-field-integration.md](05-form-field-integration.md) — `FormField` standalone и как `fieldWrapper`.
160
+ - [06-troubleshooting.md](06-troubleshooting.md) — типичные проблемы и решения.
161
+
162
+ ## 6. Components
163
+
164
+ | Name | Purpose | When to use |
165
+ | --------------- | -------------------------------------------------------------------------------------- | ------------------------------------- |
166
+ | `Input` | Однострочное поле, поддерживает `type='text'/'email'/'number'/'tel'/'url'/'password'`. | По умолчанию для строк и чисел. |
167
+ | `InputMask` | `Input` + строковая маска (`'9'` → цифра). | Телефоны, ИНН, даты. |
168
+ | `InputPassword` | Поле пароля с переключателем «глаз». | Регистрация, логин, смена пароля. |
169
+ | `Textarea` | Многострочное поле с `rows`/`maxLength`. | Комментарии, адрес, длинные описания. |
170
+
171
+ ## 7. Input
172
+
173
+ ### API
174
+
175
+ ```typescript
176
+ interface InputProps {
177
+ className?: string;
178
+ value?: string | number | null;
179
+ onChange?: (value: string | number | null) => void;
180
+ onBlur?: () => void;
181
+ type?: 'text' | 'email' | 'number' | 'tel' | 'url' | 'password';
182
+ placeholder?: string;
183
+ disabled?: boolean;
184
+ // плюс все нативные props кроме value/onChange:
185
+ // min, max, step, autoComplete, name, id, aria-*, data-*
186
+ }
187
+ ```
188
+
189
+ | Prop | Тип | Default | Описание |
190
+ | ------------- | ------------------------------------------- | --------------- | -------------------------------------------------------------------------- |
191
+ | `value` | `string \| number \| null` | `''` рендерится | Текущее значение. `null`/`undefined` → пустое поле. |
192
+ | `onChange` | `(value: string \| number \| null) => void` | — | Вызывается при вводе. Пустая строка → `null`. Для `type='number'` — число. |
193
+ | `onBlur` | `() => void` | — | Срабатывает при потере фокуса. |
194
+ | `type` | union | `'text'` | HTML `type`. Для `'number'` включается числовой парсинг. |
195
+ | `placeholder` | `string` | — | Подсказка. |
196
+ | `disabled` | `boolean` | `false` | Блокирует ввод и редактирование. |
197
+
198
+ ### Common Patterns
199
+
200
+ Базовый ввод (текст):
201
+
202
+ ```tsx
203
+ import { Input } from '@reformer/ui-kit';
204
+
205
+ <Input value={name} onChange={setName} placeholder="Имя" />;
206
+ ```
207
+
208
+ Числовое поле (с `min`):
209
+
210
+ ```tsx
211
+ <Input type="number" value={age} onChange={setAge} min={0} placeholder="Возраст" />
212
+ ```
213
+
214
+ > **Edge case `type='number'`.** Пустой ввод даёт `null` (а не `''`). При `min >= 0`
215
+ > любое отрицательное значение принудительно становится `0`. `NaN` не
216
+ > прокидывается — `onChange` просто не вызывается. Поэтому в форме поле должно
217
+ > иметь тип `number | null`, а не `number`.
218
+
219
+ Email-валидация на уровне формы:
220
+
221
+ ```tsx
222
+ import { createForm, type FormSchema } from '@reformer/core';
223
+ import { Input } from '@reformer/ui-kit';
224
+ import { string, email, pipe } from 'valibot';
225
+
226
+ const form = createForm<FormSchema<{ email: string }>>({
227
+ email: { component: Input, validations: [pipe(string(), email())] },
228
+ });
229
+
230
+ <Input value={form.email.value} onChange={form.email.setValue} type="email" />;
231
+ ```
232
+
233
+ ### Anti-patterns
234
+
235
+ - Передавать `value: number` для `type='text'` — компонент сделает
236
+ `String(value)`, но при следующем `onChange` значение придёт строкой и
237
+ типы в форме разойдутся.
238
+ - Опускать `min={0}` и ожидать, что отрицательные числа отсекутся сами — нет,
239
+ без `min` отрицательные значения проходят.
240
+ - Перехватывать `onChange={(e) => …}` напрямую (как у нативного `<input>`).
241
+ `Input` отдаёт сразу значение, а не event.
242
+
243
+ ## 8. InputMask
244
+
245
+ ### API
246
+
247
+ ```typescript
248
+ interface InputMaskProps {
249
+ className?: string;
250
+ value?: string | null;
251
+ onChange?: (value: string | null) => void;
252
+ onBlur?: () => void;
253
+ mask?: string; // '9' = цифра, остальные символы — литералы
254
+ placeholder?: string; // если опущен — используется mask
255
+ disabled?: boolean;
256
+ }
257
+ ```
258
+
259
+ | Prop | Тип | Default | Описание |
260
+ | ------------- | -------- | ------- | ---------------------------------------------------------------------------------------------- |
261
+ | `mask` | `string` | — | Шаблон маски. `9` означает «цифра», остальные символы (`+`, `-`, `(`, `)`, пробел) — литералы. |
262
+ | `placeholder` | `string` | `mask` | Подсказка. По умолчанию равна маске для подсветки формата. |
263
+
264
+ ### Common Patterns
265
+
266
+ Российский телефон:
267
+
268
+ ```tsx
269
+ import { InputMask } from '@reformer/ui-kit';
270
+
271
+ <InputMask value={phone} onChange={setPhone} mask="+7 (999) 999-99-99" />;
272
+ ```
273
+
274
+ ИНН (10 цифр):
275
+
276
+ ```tsx
277
+ <InputMask value={inn} onChange={setInn} mask="9999999999" placeholder="ИНН" />
278
+ ```
279
+
280
+ Дата `DD.MM.YYYY`:
281
+
282
+ ```tsx
283
+ <InputMask value={birthDate} onChange={setBirthDate} mask="99.99.9999" />
284
+ ```
285
+
286
+ ### Anti-patterns
287
+
288
+ - Считать, что `value` хранится без литералов маски. На самом деле `value` — это
289
+ ровно то, что введено пользователем, **с** литералами. Очистку (только цифры)
290
+ нужно делать в behavior `transformValue` или при сабмите.
291
+ - Использовать `mask` для сложных правил (валидация диапазонов, контрольные
292
+ суммы) — `InputMask` только направляет ввод, не валидирует. Валидацию вешать
293
+ через `validations` в schema поля.
294
+
295
+ ## 9. InputPassword
296
+
297
+ ### API
298
+
299
+ ```typescript
300
+ interface InputPasswordProps {
301
+ className?: string;
302
+ value?: string | null;
303
+ onChange?: (value: string | null) => void;
304
+ onBlur?: () => void;
305
+ placeholder?: string; // default: 'Password'
306
+ disabled?: boolean;
307
+ showToggle?: boolean; // default: true — показывать кнопку «глаз»
308
+ }
309
+ ```
310
+
311
+ | Prop | Тип | Default | Описание |
312
+ | ------------- | --------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------- |
313
+ | `showToggle` | `boolean` | `true` | Показывать ли иконку «глаз»/«перечеркнутый глаз» для переключения видимости. Иконка показывается только когда `value` непустой. |
314
+ | `placeholder` | `string` | `'Password'` | Подсказка. |
315
+
316
+ ### Common Patterns
317
+
318
+ Дефолт (с переключателем):
319
+
320
+ ```tsx
321
+ import { InputPassword } from '@reformer/ui-kit';
322
+
323
+ <InputPassword value={password} onChange={setPassword} placeholder="Пароль" />;
324
+ ```
325
+
326
+ Без переключателя видимости:
327
+
328
+ ```tsx
329
+ <InputPassword value={password} onChange={setPassword} showToggle={false} />
330
+ ```
331
+
332
+ Подтверждение пароля (через `compute-from` / `revalidate-when` на уровне формы):
333
+
334
+ ```tsx
335
+ <InputPassword value={form.password.value} onChange={form.password.setValue} />
336
+ <InputPassword
337
+ value={form.passwordConfirm.value}
338
+ onChange={form.passwordConfirm.setValue}
339
+ placeholder="Повторите пароль"
340
+ />
341
+ ```
342
+
343
+ ### Anti-patterns
344
+
345
+ - Использовать `<Input type="password">` вместо `InputPassword`, если нужен
346
+ переключатель видимости — `Input` его не имеет.
347
+ - Хранить пароль с побочными состояниями (`maskedValue`, `realValue`). Компонент
348
+ всегда отдаёт raw-строку через `onChange`; маскирование — задача браузера.
349
+
350
+ ## 10. Textarea
351
+
352
+ ### API
353
+
354
+ ```typescript
355
+ interface TextareaProps {
356
+ className?: string;
357
+ value?: string | null;
358
+ onChange?: (value: string | null) => void;
359
+ onBlur?: () => void;
360
+ placeholder?: string;
361
+ disabled?: boolean;
362
+ rows?: number; // default: 3
363
+ maxLength?: number;
364
+ }
365
+ ```
366
+
367
+ | Prop | Тип | Default | Описание |
368
+ | ----------- | -------- | ------- | -------------------------------------------------------------------- |
369
+ | `rows` | `number` | `3` | Видимая высота в строках. Resize по вертикали оставлен (`resize-y`). |
370
+ | `maxLength` | `number` | — | Жёсткое ограничение длины (нативное HTML-поведение). |
371
+
372
+ ### Common Patterns
373
+
374
+ Комментарий с лимитом:
375
+
376
+ ```tsx
377
+ import { Textarea } from '@reformer/ui-kit';
378
+
379
+ <Textarea
380
+ value={comment}
381
+ onChange={setComment}
382
+ rows={5}
383
+ maxLength={500}
384
+ placeholder="Опишите проблему"
385
+ />;
386
+ ```
387
+
388
+ Адрес доставки:
389
+
390
+ ```tsx
391
+ <Textarea value={address} onChange={setAddress} rows={3} placeholder="Адрес" />
392
+ ```
393
+
394
+ ### Anti-patterns
395
+
396
+ - Передавать `rows={1}` — для одной строки используйте `Input`. Textarea не
397
+ имеет логики авто-роста.
398
+ - Полагаться на `maxLength` как валидатор: это soft-лимит на ввод; для бизнес-
399
+ правил (например, `длина <= 500 на русском, <= 1000 на английском`) ставить
400
+ `validations` в `createForm`.
401
+
402
+ ## 11. See also
403
+
404
+ - [03-choice-fields.md](03-choice-fields.md) — Select, Checkbox, RadioGroup.
405
+ - [05-form-field-integration.md](05-form-field-integration.md) — как все эти поля автоматически подключаются через `FormField`.
406
+ - [06-troubleshooting.md](06-troubleshooting.md) — «number возвращает строку», «mask пропускает символы», «password toggle не появляется».
407
+ - Эталон: `RegistrationForm.tsx`, `credit-application-schema.ts` (monorepo examples).
408
+
409
+ ## 12. Checkbox
410
+
411
+ ### API
412
+
413
+ ```typescript
414
+ interface CheckboxProps {
415
+ className?: string;
416
+ value?: boolean;
417
+ onChange?: (value: boolean) => void;
418
+ onBlur?: () => void;
419
+ label?: string;
420
+ disabled?: boolean;
421
+ 'data-testid'?: string;
422
+ }
423
+ ```
424
+
425
+ | Prop | Тип | Default | Описание |
426
+ | ---------- | -------------------------- | ------- | ------------------------------------------------------------------------ |
427
+ | `value` | `boolean` | `false` | Чекнут или нет. `undefined` → `false`. |
428
+ | `onChange` | `(value: boolean) => void` | — | Вызывается с `event.target.checked`. |
429
+ | `label` | `string` | — | Подпись справа от чекбокса. Если опущен — рендерится только сам чекбокс. |
430
+ | `disabled` | `boolean` | `false` | Блокирует переключение. |
431
+
432
+ ### Common Patterns
433
+
434
+ Согласие с условиями:
435
+
436
+ ```tsx
437
+ import { Checkbox } from '@reformer/ui-kit';
438
+
439
+ <Checkbox value={agree} onChange={setAgree} label="Согласен с условиями" />;
440
+ ```
441
+
442
+ Чекбокс без подписи (label рендерится снаружи или не нужен):
443
+
444
+ ```tsx
445
+ <div className="flex items-center gap-2">
446
+ <Checkbox value={hasMortgage} onChange={setHasMortgage} />
447
+ <span>У меня уже есть ипотека</span>
448
+ </div>
449
+ ```
450
+
451
+ В составе формы (`FormField` сам определяет, что это checkbox, и не дублирует
452
+ label сверху):
453
+
454
+ ```tsx
455
+ import { createForm, type FormSchema } from '@reformer/core';
456
+ import { Checkbox, FormField } from '@reformer/ui-kit';
457
+
458
+ const form = createForm<FormSchema<{ accept: boolean }>>({
459
+ accept: { component: Checkbox, value: false, componentProps: { label: 'Принять' } },
460
+ });
461
+
462
+ <FormField control={form.accept} testId="accept" />;
463
+ ```
464
+
465
+ ### Anti-patterns
466
+
467
+ - Передавать `value: 'yes' | 'no'` (строку) — `Checkbox` ожидает `boolean`. Для
468
+ строкового выбора используйте `RadioGroup` (два варианта) или `Select`.
469
+ - Делать `<Checkbox checked={x} onChange={…}>` (как с нативным `<input
470
+ type="checkbox">`) — пропа `checked` нет, нужно `value`.
471
+
472
+ ## 13. RadioGroup
473
+
474
+ ### API
475
+
476
+ ```typescript
477
+ interface RadioOption {
478
+ value: string;
479
+ label: string;
480
+ }
481
+
482
+ interface RadioGroupProps {
483
+ className?: string;
484
+ value?: string | null;
485
+ onChange?: (value: string) => void;
486
+ onBlur?: () => void;
487
+ options: RadioOption[];
488
+ disabled?: boolean;
489
+ 'data-testid'?: string;
490
+ }
491
+ ```
492
+
493
+ | Prop | Тип | Default | Описание |
494
+ | ---------- | ------------------------- | ------- | ------------------------------------------------------------------ |
495
+ | `options` | `RadioOption[]` | — | Список вариантов. `value` обязан быть строкой. |
496
+ | `value` | `string \| null` | `null` | Выбранный вариант. Должен совпадать с одним из `options[i].value`. |
497
+ | `onChange` | `(value: string) => void` | — | Вызывается при выборе. Передаётся `event.target.value`. |
498
+ | `disabled` | `boolean` | `false` | Блокирует все варианты. |
499
+
500
+ По умолчанию варианты раскладываются вертикально (`flex flex-col gap-2`).
501
+
502
+ ### Common Patterns
503
+
504
+ Вертикальная раскладка (default):
505
+
506
+ ```tsx
507
+ import { RadioGroup } from '@reformer/ui-kit';
508
+
509
+ const LOAN_TYPES = [
510
+ { value: 'consumer', label: 'Потребительский' },
511
+ { value: 'mortgage', label: 'Ипотека' },
512
+ { value: 'auto', label: 'Авто' },
513
+ ];
514
+
515
+ <RadioGroup value={loanType} onChange={setLoanType} options={LOAN_TYPES} />;
516
+ ```
517
+
518
+ Горизонтальная раскладка (через `className`):
519
+
520
+ ```tsx
521
+ <RadioGroup
522
+ value={size}
523
+ onChange={setSize}
524
+ options={[
525
+ { value: 's', label: 'S' },
526
+ { value: 'm', label: 'M' },
527
+ { value: 'l', label: 'L' },
528
+ ]}
529
+ className="!flex-row gap-6"
530
+ />
531
+ ```
532
+
533
+ В составе формы:
534
+
535
+ ```tsx
536
+ const form = createForm<FormSchema<{ loanType: string }>>({
537
+ loanType: {
538
+ component: RadioGroup,
539
+ value: 'consumer',
540
+ componentProps: { options: LOAN_TYPES },
541
+ },
542
+ });
543
+
544
+ <FormField control={form.loanType} testId="loan-type" />;
545
+ ```
546
+
547
+ ### Anti-patterns
548
+
549
+ - Передавать `options` с числовыми `value` — компонент ставит их в DOM-атрибут
550
+ `value`, который всегда строка, и `onChange` вернёт строку. Это рассинхронит
551
+ типы. Если нужны числа — конвертируй на уровне behavior `transformValue`.
552
+ - Динамически менять список `options` без пересоздания компонента — текущее
553
+ `value` может оказаться вне набора, и ничего не выбрано визуально.
554
+ - Ожидать, что `onBlur` сработает после клика на radio — он срабатывает на
555
+ `blur` нативного input, как обычно. Для пометки `touched` после взаимодействия
556
+ обычно достаточно `onChange`.
557
+
558
+ ## 14. Select
559
+
560
+ `Select` построен поверх `@radix-ui/react-select`. Имеет два режима источника
561
+ данных:
562
+
563
+ - **Inline**: `options={[…]}` — массив `{ value, label, group? }`.
564
+ - **Resource**: `resource={{ type, load }}` — асинхронная загрузка (см. ниже).
565
+
566
+ ### API
567
+
568
+ ```typescript
569
+ interface ResourceConfig<T> {
570
+ type: 'static' | 'preload' | 'partial';
571
+ load: (params?: ResourceLoadParams) => Promise<{
572
+ items: Array<{ id: string | number; label: string; value: T; group?: string }>;
573
+ totalCount: number;
574
+ }>;
575
+ }
576
+
577
+ interface SelectProps<T> {
578
+ className?: string;
579
+ value?: string | null;
580
+ onChange?: (value: string | null) => void;
581
+ onBlur?: () => void;
582
+ resource?: ResourceConfig<T>;
583
+ options?: Array<{ value: string | number; label: string; group?: string }>;
584
+ placeholder?: string;
585
+ disabled?: boolean;
586
+ clearable?: boolean; // показать кнопку очистки (X)
587
+ 'data-testid'?: string;
588
+ 'aria-invalid'?: boolean | 'true' | 'false';
589
+ }
590
+ ```
591
+
592
+ | Prop | Тип | Default | Описание |
593
+ | ------------- | --------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
594
+ | `options` | `Array<{value,label,group?}>` | — | Inline-варианты. `value` приводится к строке. `group` опционально — варианты с одинаковым `group` объединяются в `SelectGroup` с `SelectLabel`. |
595
+ | `resource` | `ResourceConfig<T>` | — | Асинхронный источник. На маунт вызывается `resource.load({})`. На время загрузки `Select` показывает `Loading...` и блокируется. |
596
+ | `value` | `string \| null` | `null` | Выбранное значение (всегда строка из `option.value`). |
597
+ | `onChange` | `(value: string \| null) => void` | — | Срабатывает при выборе. При нажатии на крестик (`clearable`) приходит `null`. |
598
+ | `placeholder` | `string` | `'Select an option...'` | Подсказка в триггере. |
599
+ | `clearable` | `boolean` | `false` | Показать кнопку очистки справа от значения (только когда `value` непустой). |
600
+ | `disabled` | `boolean` | `false` | Блокирует выбор. |
601
+
602
+ ### Sub-components
603
+
604
+ Все рендерятся `Select` автоматически, но при необходимости их можно
605
+ импортировать и собрать кастомный layout:
606
+
607
+ | Component | Purpose |
608
+ | ------------------------ | --------------------------------------------------------------------------------------- |
609
+ | `SelectGroup` | Обёртка над `Radix.Select.Group`. Группирует `SelectItem`. |
610
+ | `SelectValue` | Отображает выбранное значение в триггере. |
611
+ | `SelectTrigger` | Кнопка-открывалка. Принимает `size: 'sm' \| 'default'`. |
612
+ | `SelectContent` | Дропдаун-портал со списком. Включает `SelectScrollUpButton` / `SelectScrollDownButton`. |
613
+ | `SelectLabel` | Заголовок группы (рендерится в `SelectGroup`). |
614
+ | `SelectItem` | Одна опция. С `CheckIcon`-индикатором, если выбрана. |
615
+ | `SelectScrollUpButton` | Стрелка скролла вверх. |
616
+ | `SelectScrollDownButton` | Стрелка скролла вниз. |
617
+
618
+ ### Common Patterns
619
+
620
+ Inline `options`:
621
+
622
+ ```tsx
623
+ import { Select } from '@reformer/ui-kit';
624
+
625
+ <Select
626
+ value={loanType}
627
+ onChange={setLoanType}
628
+ placeholder="Тип кредита"
629
+ options={[
630
+ { value: 'consumer', label: 'Потребительский' },
631
+ { value: 'mortgage', label: 'Ипотека' },
632
+ ]}
633
+ />;
634
+ ```
635
+
636
+ Async `resource` (пример: список банков):
637
+
638
+ ```tsx
639
+ import { Select, type ResourceConfig } from '@reformer/ui-kit/select';
640
+
641
+ const banksResource: ResourceConfig<string> = {
642
+ type: 'preload',
643
+ load: async () => {
644
+ const res = await fetch('/api/banks');
645
+ const banks: Array<{ id: number; name: string }> = await res.json();
646
+ return {
647
+ items: banks.map((b) => ({ id: b.id, value: String(b.id), label: b.name })),
648
+ totalCount: banks.length,
649
+ };
650
+ },
651
+ };
652
+
653
+ <Select value={bankId} onChange={setBankId} resource={banksResource} />;
654
+ ```
655
+
656
+ Grouped options:
657
+
658
+ ```tsx
659
+ <Select
660
+ value={city}
661
+ onChange={setCity}
662
+ options={[
663
+ { value: 'msk', label: 'Москва', group: 'Россия' },
664
+ { value: 'spb', label: 'Санкт-Петербург', group: 'Россия' },
665
+ { value: 'minsk', label: 'Минск', group: 'Беларусь' },
666
+ { value: 'kiev', label: 'Киев', group: 'Украина' },
667
+ ]}
668
+ />
669
+ ```
670
+
671
+ `clearable` (с очисткой):
672
+
673
+ ```tsx
674
+ <Select
675
+ value={status}
676
+ onChange={setStatus}
677
+ clearable
678
+ placeholder="Любой"
679
+ options={[
680
+ { value: 'open', label: 'Открыт' },
681
+ { value: 'closed', label: 'Закрыт' },
682
+ ]}
683
+ />
684
+ ```
685
+
686
+ В составе формы:
687
+
688
+ ```tsx
689
+ const form = createForm<FormSchema<{ city: string }>>({
690
+ city: {
691
+ component: Select,
692
+ componentProps: {
693
+ placeholder: 'Город',
694
+ options: [
695
+ { value: 'msk', label: 'Москва' },
696
+ { value: 'spb', label: 'Санкт-Петербург' },
697
+ ],
698
+ },
699
+ },
700
+ });
701
+
702
+ <FormField control={form.city} testId="city" />;
703
+ ```
704
+
705
+ ### Anti-patterns
706
+
707
+ - Передавать одновременно `options` и `resource` — `options` приоритетнее,
708
+ `resource.load` всё равно вызовется на маунт (лишний запрос). Выбирай один
709
+ источник.
710
+ - Опускать `value` (`undefined`) — Radix покажет placeholder, но сам компонент
711
+ всегда мапит `undefined` в пустую строку. Лучше явно `null`.
712
+ - Использовать `value: number` напрямую — `Select` приводит к строке внутри
713
+ (`String(value)`); `onChange` вернёт строку. В schema формы тип поля должен
714
+ быть `string` или `string | null`.
715
+ - Регистрировать `Select` без `placeholder` и ждать понятного UX —
716
+ пользователь увидит дефолт `'Select an option...'`. Для русскоязычных форм
717
+ это, как правило, нежелательно.
718
+
719
+ ## 15. See also
720
+
721
+ - [02-text-fields.md](02-text-fields.md) — `Input`, `InputMask`, `InputPassword`, `Textarea`.
722
+ - [05-form-field-integration.md](05-form-field-integration.md) — `FormField` распознаёт `Checkbox` и не дублирует label.
723
+ - [06-troubleshooting.md](06-troubleshooting.md) — «Select не показывает options», «options vs resource», «onBlur не срабатывает на Select/RadioGroup».
724
+ - Эталон: `credit-application-schema.ts` (monorepo example) — большой пример с `Select` и `Checkbox` в реальной форме.
725
+
726
+ ## 16. Button
727
+
728
+ Кнопка на shadcn/Radix `Slot`. Поддерживает 6 вариантов внешнего вида, 6
729
+ размеров и режим `asChild` для замены DOM-узла (типичный кейс — превратить
730
+ кнопку в `<a>` или `<Link>` без потери стилей).
731
+
732
+ ### API
733
+
734
+ ```typescript
735
+ interface ButtonProps extends React.ComponentProps<'button'> {
736
+ variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
737
+ size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
738
+ asChild?: boolean;
739
+ }
740
+ ```
741
+
742
+ | Variant | Use case |
743
+ | ------------- | ------------------------------------------------------------------- |
744
+ | `default` | Основное действие (`Submit`, `Save`). Заполненный фон `bg-primary`. |
745
+ | `destructive` | Опасное действие (`Delete`, `Remove`). |
746
+ | `outline` | Вторичное действие (`Cancel`, `Edit`). Прозрачный фон + бордер. |
747
+ | `secondary` | Между `default` и `outline`. Серый фон. |
748
+ | `ghost` | Меню, иконки в toolbar. Без фона до hover. |
749
+ | `link` | Текстовая ссылка с подчёркиванием на hover. |
750
+
751
+ | Size | Высота | Использование |
752
+ | --------- | --------- | -------------------------------------- |
753
+ | `default` | `h-9` | Дефолтный размер для большинства форм. |
754
+ | `sm` | `h-8` | Компактные toolbar-ы, фильтры. |
755
+ | `lg` | `h-10` | Финальный CTA, оплата. |
756
+ | `icon` | `size-9` | Только иконка, default-размер. |
757
+ | `icon-sm` | `size-8` | Иконка в toolbar. |
758
+ | `icon-lg` | `size-10` | Иконка hero. |
759
+
760
+ | Prop | Тип | Default | Описание |
761
+ | --------- | --------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------- |
762
+ | `variant` | union | `'default'` | Внешний вид (см. таблицу). |
763
+ | `size` | union | `'default'` | Размер (см. таблицу). |
764
+ | `asChild` | `boolean` | `false` | Заменить корневой `<button>` на дочерний элемент через `@radix-ui/react-slot`. Требует ровно одного React-элемента в `children`. |
765
+
766
+ Все остальные пропсы (`onClick`, `disabled`, `type`, `aria-*`, `data-*`)
767
+ прокидываются как у нативного `<button>`.
768
+
769
+ ### Common Patterns
770
+
771
+ Submit формы:
772
+
773
+ ```tsx
774
+ import { Button } from '@reformer/ui-kit';
775
+
776
+ <Button type="submit" disabled={isSubmitting}>
777
+ {isSubmitting ? 'Отправка...' : 'Отправить'}
778
+ </Button>;
779
+ ```
780
+
781
+ Variants matrix (для design-system документации):
782
+
783
+ ```tsx
784
+ {
785
+ (['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'] as const).map((variant) => (
786
+ <Button key={variant} variant={variant}>
787
+ {variant}
788
+ </Button>
789
+ ));
790
+ }
791
+ ```
792
+
793
+ `asChild` + react-router:
794
+
795
+ ```tsx
796
+ import { Link } from 'react-router-dom';
797
+ import { Button } from '@reformer/ui-kit';
798
+
799
+ <Button asChild variant="outline">
800
+ <Link to="/dashboard">Открыть дашборд</Link>
801
+ </Button>;
802
+ ```
803
+
804
+ `asChild` + `<a download>`:
805
+
806
+ ```tsx
807
+ <Button asChild>
808
+ <a href="/report.pdf" download>
809
+ Скачать отчёт
810
+ </a>
811
+ </Button>
812
+ ```
813
+
814
+ Иконка в кнопке (Lucide):
815
+
816
+ ```tsx
817
+ import { PlusIcon } from 'lucide-react';
818
+
819
+ <Button size="sm">
820
+ <PlusIcon /> Добавить
821
+ </Button>;
822
+ ```
823
+
824
+ Только иконка:
825
+
826
+ ```tsx
827
+ <Button size="icon" variant="ghost" aria-label="Закрыть">
828
+ <XIcon />
829
+ </Button>
830
+ ```
831
+
832
+ ### Anti-patterns
833
+
834
+ - `asChild` с несколькими элементами в `children` — Radix Slot падает; нужен
835
+ ровно один React-элемент.
836
+ - `<Button as="a">` — у `Button` нет prop'а `as`, используй `asChild`.
837
+ - Передавать `className` для смены `variant`-цветов вместо одной из
838
+ вариант-опций — теряется консистентность темы.
839
+ - `size="icon"` без иконки — будет квадрат `h-9 w-9` без видимого контента.
840
+
841
+ ## 17. AsyncBoundary
842
+
843
+ Минималистичный switch на три состояния: `loading` / `error` / `ready`.
844
+ Используется для оборачивания экранов, зависящих от внешних данных
845
+ (profile, dictionaries).
846
+
847
+ ### API
848
+
849
+ ```typescript
850
+ type AsyncStatus = 'loading' | 'error' | 'ready';
851
+
852
+ interface AsyncBoundaryProps {
853
+ status: AsyncStatus;
854
+ LoadingComponent?: React.ComponentType;
855
+ ErrorComponent?: React.ComponentType;
856
+ children?: React.ReactNode;
857
+ }
858
+ ```
859
+
860
+ | Prop | Тип | Описание |
861
+ | ------------------ | --------------- | -------------------------------------------------------------------------------------- |
862
+ | `status` | `AsyncStatus` | Текущее состояние. Управляется снаружи. |
863
+ | `LoadingComponent` | `ComponentType` | Рендерится при `status === 'loading'`. Без props. Если не передан — рендерится `null`. |
864
+ | `ErrorComponent` | `ComponentType` | Рендерится при `status === 'error'`. Без props. Если не передан — `null`. |
865
+ | `children` | `ReactNode` | Рендерится при `status === 'ready'`. |
866
+
867
+ Оба слота — `ComponentType`, не `ReactNode`. Для передачи props (текста ошибки,
868
+ `retry`-callback) — оберни в тонкий компонент:
869
+
870
+ ```tsx
871
+ const Loading = () => <div className="py-12 text-center">Загрузка...</div>;
872
+ const ErrorView = () => (
873
+ <div className="py-12 text-center text-destructive">
874
+ Не удалось загрузить данные. <button onClick={retry}>Повторить</button>
875
+ </div>
876
+ );
877
+
878
+ <AsyncBoundary status={status} LoadingComponent={Loading} ErrorComponent={ErrorView}>
879
+ <Profile data={data} />
880
+ </AsyncBoundary>;
881
+ ```
882
+
883
+ ### Common Patterns
884
+
885
+ С хуком загрузки:
886
+
887
+ ```tsx
888
+ import { useEffect, useState } from 'react';
889
+ import { AsyncBoundary, type AsyncStatus } from '@reformer/ui-kit';
890
+
891
+ function CountriesPage() {
892
+ const [status, setStatus] = useState<AsyncStatus>('loading');
893
+ const [countries, setCountries] = useState<string[]>([]);
894
+
895
+ useEffect(() => {
896
+ fetch('/api/countries')
897
+ .then((r) => r.json())
898
+ .then((data) => {
899
+ setCountries(data);
900
+ setStatus('ready');
901
+ })
902
+ .catch(() => setStatus('error'));
903
+ }, []);
904
+
905
+ return (
906
+ <AsyncBoundary
907
+ status={status}
908
+ LoadingComponent={() => <p>Загружаем страны...</p>}
909
+ ErrorComponent={() => <p>Ошибка загрузки</p>}
910
+ >
911
+ <ul>
912
+ {countries.map((c) => (
913
+ <li key={c}>{c}</li>
914
+ ))}
915
+ </ul>
916
+ </AsyncBoundary>
917
+ );
918
+ }
919
+ ```
920
+
921
+ ### Anti-patterns
922
+
923
+ - Передавать в `LoadingComponent` готовый `<div>` (ReactNode) вместо
924
+ компонента — будет ошибка типов; нужно `() => <div>...</div>`.
925
+ - Использовать `AsyncBoundary` вместо `Suspense` для React-Suspense-данных —
926
+ это разные механизмы. `AsyncBoundary` — простая state-машина, не
927
+ перехватывает throw.
928
+
929
+ ## 18. ExampleCard
930
+
931
+ Карточка-демонстрация для playground: заголовок, описание, область с примером
932
+ и переключатель `пример ↔ исходник` с кнопкой копирования.
933
+
934
+ ### API
935
+
936
+ ```typescript
937
+ interface ExampleCardProps {
938
+ title: string;
939
+ description?: string;
940
+ children: React.ReactNode;
941
+ code: string;
942
+ className?: string;
943
+ bgColor?: string;
944
+ }
945
+ ```
946
+
947
+ | Prop | Тип | Default | Описание |
948
+ | ------------- | -------- | ------------ | ---------------------------------------- |
949
+ | `title` | `string` | — | Заголовок карточки. |
950
+ | `description` | `string` | — | Описание под заголовком. |
951
+ | `code` | `string` | — | Текст исходника, копируется в clipboard. |
952
+ | `bgColor` | `string` | `'bg-white'` | Tailwind-класс фона карточки. |
953
+
954
+ ### Common Patterns
955
+
956
+ ```tsx
957
+ import { ExampleCard, Input } from '@reformer/ui-kit';
958
+
959
+ <ExampleCard
960
+ title="Input — базовый"
961
+ description="Однострочное поле с placeholder"
962
+ code={`<Input value={v} onChange={setV} placeholder="Email" />`}
963
+ >
964
+ <Input value={v} onChange={setV} placeholder="Email" />
965
+ </ExampleCard>;
966
+ ```
967
+
968
+ ### Anti-patterns
969
+
970
+ - Использовать в продакшене — это playground-utility, не component-library
971
+ primitive. Кнопка переключения «глаз/код» не настраивается.
972
+
973
+ ## 19. cn
974
+
975
+ Утилита для конкатенации Tailwind-классов через `clsx` и `tailwind-merge`.
976
+ Разрешает конфликты (последний выигрывает) — критично для условного оверрайда
977
+ classN'ов.
978
+
979
+ ### Common Patterns
980
+
981
+ Условные классы:
982
+
983
+ ```typescript
984
+ import { cn } from '@reformer/ui-kit';
985
+
986
+ cn('px-2 py-1', isActive && 'bg-blue-500', 'px-4');
987
+ // → 'py-1 bg-blue-500 px-4' (px-2 затёрт px-4)
988
+ ```
989
+
990
+ В forwardRef-компоненте:
991
+
992
+ ```tsx
993
+ import { cn } from '@reformer/ui-kit';
994
+
995
+ const Card = React.forwardRef<HTMLDivElement, { className?: string }>(
996
+ ({ className, ...props }, ref) => (
997
+ <div ref={ref} className={cn('rounded-lg border p-4', className)} {...props} />
998
+ )
999
+ );
1000
+ ```
1001
+
1002
+ ### Anti-patterns
1003
+
1004
+ - Использовать `cn` вместо строки в случае без условий — `cn('a b c')` работает,
1005
+ но избыточен. Достаточно `'a b c'`.
1006
+ - Передавать массивы/объекты, рассчитывая на shadcn-стиль `cn({active: true})`:
1007
+ `clsx`-синтаксис поддерживается, но удобнее писать через `&&`.
1008
+
1009
+ ## 20. See also
1010
+
1011
+ - [05-form-field-integration.md](05-form-field-integration.md) — как `Button` используется в `FormWizard.Actions`.
1012
+ - [06-troubleshooting.md](06-troubleshooting.md) — «forwardRef + Slot конфликты», «AsyncBoundary не переключает состояние».
1013
+
1014
+ ## 21. API
1015
+
1016
+ ```typescript
1017
+ interface FormFieldProps {
1018
+ control: FieldNode<any>;
1019
+ className?: string;
1020
+ testId?: string;
1021
+ /** Кастомный input — для использования с RenderSchema fieldWrapper */
1022
+ children?: React.ReactNode;
1023
+ }
1024
+ ```
1025
+
1026
+ | Prop | Тип | Описание |
1027
+ | ----------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1028
+ | `control` | `FieldNode<T>` | Поле формы. Из него берутся `component`, `componentProps`, `value`, `error`, `pending`, `setValue`, `blur`. |
1029
+ | `className` | `string` | Класс корневой `<div>`-обёртки. |
1030
+ | `testId` | `string` | Префикс для `data-testid` (`field-<id>`, `label-<id>`, `input-<id>`, `error-<id>`). Если опущен — пытается взять `componentProps.testId`. Иначе `'unknown'`. |
1031
+ | `children` | `ReactNode` | Кастомный контрол: оборачивается в `CdkFormField.Control asChild`. См. сценарий 3. |
1032
+
1033
+ `FormField` обёрнут в `React.memo` со сравнением по ссылочному равенству
1034
+ `control` — это критично для производительности больших форм (при ререндере
1035
+ родителя поле не пересчитывается, пока не сменился сам `FieldNode`).
1036
+
1037
+ ## 22. Common Patterns
1038
+
1039
+ ### 1. Standalone
1040
+
1041
+ Самый частый сценарий: ручная форма с `FormField`-ами для каждого поля.
1042
+
1043
+ ```tsx
1044
+ import { useMemo } from 'react';
1045
+ import { createForm, type FormSchema } from '@reformer/core';
1046
+ import { Button, FormField, Input, Select } from '@reformer/ui-kit';
1047
+
1048
+ interface RegistrationForm {
1049
+ email: string;
1050
+ country: string;
1051
+ }
1052
+
1053
+ function RegistrationPage() {
1054
+ const form = useMemo(
1055
+ () =>
1056
+ createForm<FormSchema<RegistrationForm>>({
1057
+ email: {
1058
+ component: Input,
1059
+ componentProps: { label: 'Email', placeholder: 'you@example.com' },
1060
+ },
1061
+ country: {
1062
+ component: Select,
1063
+ componentProps: {
1064
+ label: 'Страна',
1065
+ options: [
1066
+ { value: 'ru', label: 'Россия' },
1067
+ { value: 'by', label: 'Беларусь' },
1068
+ ],
1069
+ },
1070
+ },
1071
+ }),
1072
+ []
1073
+ );
1074
+
1075
+ return (
1076
+ <form className="space-y-4">
1077
+ <FormField control={form.email} testId="email" />
1078
+ <FormField control={form.country} testId="country" />
1079
+ <Button type="submit">Регистрация</Button>
1080
+ </form>
1081
+ );
1082
+ }
1083
+ ```
1084
+
1085
+ `Label` берётся из `componentProps.label` через `CdkFormField.Label`. `error`
1086
+ автоматически появляется под полем после `validate()` или `blur()`.
1087
+
1088
+ ### 2. Как `fieldWrapper` в `FormRenderer`
1089
+
1090
+ В `@reformer/renderer-react` каждый field-узел `RenderSchema` оборачивается в
1091
+ указанный `fieldWrapper`. `FormField` из ui-kit идеально подходит для этой
1092
+ роли — он использует тот же контракт `FieldNode`, что и сам рендерер.
1093
+
1094
+ ```tsx
1095
+ import { useMemo } from 'react';
1096
+ import { FormRenderer, createRenderSchema } from '@reformer/renderer-react';
1097
+ import { FormField, Box, Section } from '@reformer/ui-kit';
1098
+ import { createCreditApplicationForm } from './schemas/create-credit-application-form';
1099
+
1100
+ function CreditApplicationPage() {
1101
+ const form = useMemo(() => createCreditApplicationForm(), []);
1102
+ const schema = useMemo(
1103
+ () =>
1104
+ createRenderSchema((path) => ({
1105
+ component: Section,
1106
+ componentProps: { title: 'Заявка', className: 'space-y-4' },
1107
+ children: [
1108
+ { component: path.email },
1109
+ { component: path.phone },
1110
+ { component: path.amount },
1111
+ ],
1112
+ })),
1113
+ []
1114
+ );
1115
+
1116
+ // settings.fieldWrapper применяется к каждому field-узлу автоматически.
1117
+ return <FormRenderer render={schema} settings={{ fieldWrapper: FormField }} />;
1118
+ }
1119
+ ```
1120
+
1121
+ Эталон: `CreditApplicationFormRenderer.tsx` (monorepo example).
1122
+
1123
+ `testId` рендерер берёт из `componentProps.testId` поля schema:
1124
+
1125
+ ```tsx
1126
+ { component: itemPath.bank, componentProps: { testId: 'existingLoan-bank' } }
1127
+ // → <FormField control={...} testId="existingLoan-bank" />
1128
+ // → data-testid="field-existingLoan-bank", "input-existingLoan-bank", ...
1129
+ ```
1130
+
1131
+ ### 3. Кастомизация через `children`
1132
+
1133
+ Для случаев, когда нужен нестандартный контрол (например, маска, которая не
1134
+ зарегистрирована в `control.component`, или комбинированный input). `children`
1135
+ оборачивается в `CdkFormField.Control asChild`, и в кастомный input
1136
+ прокидываются все нужные props (`value`, `onChange`, `onBlur`, `aria-invalid`).
1137
+
1138
+ ```tsx
1139
+ import { FormField } from '@reformer/ui-kit';
1140
+ import { InputMask } from '@reformer/ui-kit/input-mask';
1141
+
1142
+ <FormField control={form.phone} testId="phone">
1143
+ <InputMask mask="+7 (999) 999-99-99" />
1144
+ </FormField>;
1145
+ ```
1146
+
1147
+ > Важно: кастомный child должен быть единичным React-элементом и принимать
1148
+ > `value`/`onChange`/`onBlur`/`aria-invalid` (см. контракт ui-kit-полей). Для
1149
+ > сложных случаев — двух input-ов рядом — используй `CdkFormField.Root` напрямую,
1150
+ > минуя ui-kit-обёртку.
1151
+
1152
+ ### 4. Checkbox-исключение
1153
+
1154
+ `Checkbox` сам рендерит `label` рядом с собственным контролом. Если бы
1155
+ `FormField` ставил `Label` сверху, мы получили бы дубль:
1156
+
1157
+ ```
1158
+ Условия использования <-- FormField.Label (нежелательно)
1159
+ [ ] Условия использования <-- сам Checkbox
1160
+ ```
1161
+
1162
+ Поэтому `FormField` детектит `control.component === Checkbox` и не рендерит
1163
+ верхний `Label`:
1164
+
1165
+ ```tsx
1166
+ import { Checkbox, FormField } from '@reformer/ui-kit';
1167
+
1168
+ const form = createForm<FormSchema<{ accept: boolean }>>({
1169
+ accept: {
1170
+ component: Checkbox,
1171
+ value: false,
1172
+ componentProps: { label: 'Принимаю условия' },
1173
+ },
1174
+ });
1175
+
1176
+ <FormField control={form.accept} testId="accept" />;
1177
+ // рендерится только Checkbox с label справа + error снизу.
1178
+ ```
1179
+
1180
+ Проверка идёт по `===`, поэтому если ты сам реэкспортируешь `Checkbox` через
1181
+ обёртку — детектор не сработает. Решения:
1182
+
1183
+ - Использовать оригинальный `Checkbox` из `@reformer/ui-kit`.
1184
+ - Либо вручную скрывать label через `componentProps.label = undefined` и
1185
+ оборачивать обвязку самостоятельно.
1186
+
1187
+ ## 23. Anti-patterns
1188
+
1189
+ - Передавать `control` другого типа (`FormProxy`, `ArrayNode`) — ожидается
1190
+ именно `FieldNode<T>`. Для массивов используй `FormArray.Root` из CDK; для
1191
+ groups — отдельные `FormField` на каждое лиственное поле.
1192
+ - Динамически менять `control` (`<FormField control={isAdvanced ? form.x : form.y} />`)
1193
+ — `React.memo` не пересоздаст внутренний `Root`, но сам `Root` сменит контекст,
1194
+ что может привести к лишнему re-mount контрола. Предпочтительно условно
1195
+ рендерить два разных `FormField`.
1196
+ - Перекрывать `componentProps.testId` через атрибут — побеждает явный
1197
+ `testId`-prop `FormField`, поэтому смесь даёт неожиданный результат. Выбирай
1198
+ один источник.
1199
+ - Использовать `FormField` для чисто декоративных компонентов (заголовков,
1200
+ баннеров) — обёртка ставит `Label` и слот ошибки, что нелогично. Для такого
1201
+ рендерь компоненты напрямую (через container-узлы `Box`/`Section`).
1202
+
1203
+ ## 24. See also
1204
+
1205
+ - [02-text-fields.md](02-text-fields.md), [03-choice-fields.md](03-choice-fields.md) — компоненты, которые `FormField` оборачивает.
1206
+ - [04-layout-and-buttons.md](04-layout-and-buttons.md) — `Button` для submit/prev/next.
1207
+ - [06-troubleshooting.md](06-troubleshooting.md) — «label дублируется», «error не появляется», «FormField не подцепляет ошибки».
1208
+ - CDK-хуки: [@reformer/cdk/form-field](../../../reformer-cdk/docs/llms/) (`FormField.Root`, `useFormFieldContext`).
1209
+ - Эталон: `CreditApplicationFormRenderer.tsx` (monorepo example) — `FormField` как `fieldWrapper` целой multi-step формы.
1210
+
1211
+ ## 25. 1. `Input type="number"` возвращает строку, а не число (или `null`)
1212
+
1213
+ **Симптом.** В schema поле `age: number`, но в `getValue()` приходит `'42'` или
1214
+ никогда не приходит `null` для пустого ввода.
1215
+
1216
+ **Причина.** Скорее всего, ты обходишь контракт `Input` и подписываешься на
1217
+ `event` вручную: `<input onChange={(e) => setAge(e.target.value)}>`. Нативный
1218
+ `<input>` всегда отдаёт строку, даже при `type="number"`.
1219
+
1220
+ **Решение.** Использовать ui-kit-`Input` через `value`/`onChange`-контракт:
1221
+
1222
+ ```tsx
1223
+ <Input type="number" value={age} onChange={setAge} min={0} />
1224
+ // onChange приходит number | null. Пустой ввод → null. NaN не прокидывается.
1225
+ ```
1226
+
1227
+ В schema поле должно быть `number | null`, а не `number`:
1228
+
1229
+ ```typescript
1230
+ interface Form {
1231
+ age: number | null;
1232
+ }
1233
+ ```
1234
+
1235
+ ## 26. 2. `Select` не показывает options (пустой дропдаун)
1236
+
1237
+ **Симптом.** Триггер открывается, но в нём `'No options available'`.
1238
+
1239
+ **Причины и решения:**
1240
+
1241
+ - **Передан `resource`, но не передан `options`** — `resource.load({})`
1242
+ упал с ошибкой и поймался `.catch(() => setResourceOptions([]))`. Открой
1243
+ DevTools Network и посмотри статус. Скорее всего бек вернул не тот формат
1244
+ (`items: [...]` обязательно, `id` обязательно у каждого item).
1245
+
1246
+ - **Передан `options`, но `value` не строка** — внутри `Select` все `value`
1247
+ приводятся к строке (`String(opt.value)`). Если ты передаёшь
1248
+ `value: 42` (число), а `options[i].value: '42'` (строка) — Radix не
1249
+ подсветит выбранный вариант, но options будут.
1250
+
1251
+ - **Сразу и `options`, и `resource`** — `options` приоритетнее, но
1252
+ `resource.load` всё равно вызовется. Если сеть упала, `loading` может
1253
+ остаться `true`, и UI блокируется. Используй один источник.
1254
+
1255
+ ## 27. 3. `InputMask` пропускает символы или не вставляет литералы
1256
+
1257
+ **Симптом.** Пользователь вводит цифры, но скобки/тире из маски не
1258
+ появляются автоматически.
1259
+
1260
+ **Причина.** `InputMask` в текущей реализации **не** трансформирует ввод — он
1261
+ лишь служит подсказкой `placeholder`-ом, равной маске. Литералы из `mask`
1262
+ рендерятся в placeholder, но не вставляются в `value`.
1263
+
1264
+ **Решение.** Использовать поверх ui-kit отдельный mask-инструмент, либо
1265
+ форматировать значение в `behavior` `transformValue` и хранить в форме либо
1266
+ форматированное, либо «голое» значение:
1267
+
1268
+ ```typescript
1269
+ // behaviors на форме:
1270
+ transformValue(form.phone, (raw) => raw?.replace(/\D/g, '') ?? null);
1271
+ ```
1272
+
1273
+ Если для UX критичен реальный mask (с автоматической вставкой скобок), на
1274
+ данный момент компонент не покрывает эту задачу — оборачивай сторонний пакет
1275
+ (`react-imask`, `imask`) и подключай через [`05-form-field-integration.md`](05-form-field-integration.md)
1276
+ сценарий 3 (`<FormField><MaskedInput /></FormField>`).
1277
+
1278
+ ## 28. 4. `forwardRef` + Radix `Slot` конфликты
1279
+
1280
+ **Симптом.** При `<Button asChild><Link to="/">Go</Link></Button>` падает с
1281
+ `Slot: only one child or React.cloneElement is not a function`.
1282
+
1283
+ **Причины.**
1284
+
1285
+ - В `children` несколько элементов или текст рядом с элементом:
1286
+ `<Button asChild>Hello <span>!</span></Button>` — Slot допускает ровно
1287
+ один React-элемент.
1288
+ - Дочерний компонент не пробрасывает `ref` (через `React.forwardRef` или
1289
+ React 19 ref-as-prop). Slot пытается прокинуть `ref`, и если получатель —
1290
+ обычная функция, ничего не произойдёт; если внутри Slot вычисляется
1291
+ `ref`-композиция, бросает ошибку.
1292
+
1293
+ **Решение.** Проверь, что:
1294
+
1295
+ ```tsx
1296
+ <Button asChild>
1297
+ <Link to="/">Go</Link> {/* один элемент, без текста рядом */}
1298
+ </Button>
1299
+ ```
1300
+
1301
+ Для собственных контейнеров используй `React.forwardRef`:
1302
+
1303
+ ```tsx
1304
+ const MyLink = React.forwardRef<HTMLAnchorElement, { href: string; children: ReactNode }>(
1305
+ ({ href, children, ...props }, ref) => (
1306
+ <a ref={ref} href={href} {...props}>
1307
+ {children}
1308
+ </a>
1309
+ )
1310
+ );
1311
+ <Button asChild>
1312
+ <MyLink href="/x">Go</MyLink>
1313
+ </Button>;
1314
+ ```
1315
+
1316
+ ## 29. 5. `Checkbox` value не сохраняется (всегда `false`)
1317
+
1318
+ **Симптом.** Пользователь чекает, в форме пишется `true`, но при следующем
1319
+ рендере чекбокс снова пуст.
1320
+
1321
+ **Причины.**
1322
+
1323
+ - Передан `checked` вместо `value` (`<Checkbox checked={...}>`) — пропа
1324
+ `checked` нет, нужно `value`.
1325
+ - В schema поле имеет тип `boolean`, но `value: undefined` — компонент
1326
+ отрендерится как `false`, и при `setValue(true)` без вмешательства React
1327
+ re-render не произойдёт. Указывай `value: false` явно в schema.
1328
+
1329
+ ```typescript
1330
+ const form = createForm<FormSchema<{ accept: boolean }>>({
1331
+ accept: { component: Checkbox, value: false }, // не undefined!
1332
+ });
1333
+ ```
1334
+
1335
+ ## 30. 6. `FormField` не подцепляет ошибки (`<error>` не появляется)
1336
+
1337
+ **Симптом.** Поле невалидно, `form.email.error` есть, но в DOM ошибка не
1338
+ рендерится.
1339
+
1340
+ **Причины.**
1341
+
1342
+ - Используется headless-`FormField` из `@reformer/cdk` без подключения
1343
+ `<FormField.Error>`. ui-kit-`FormField` всегда вставляет `Error`, поэтому
1344
+ чаще всего проблема — путаница импортов.
1345
+ - Поле не помечено как `touched`. По умолчанию `error` вычисляется только
1346
+ после `blur` или `markAsTouched`. Если submit-кнопка не вызывает
1347
+ `form.markAsTouched()` — пользователь вообще не увидит ошибку.
1348
+
1349
+ **Решение.** На submit обязательно:
1350
+
1351
+ ```tsx
1352
+ const onSubmit = async () => {
1353
+ form.markAsTouched();
1354
+ if (!(await form.validate())) return;
1355
+ // ... отправка
1356
+ };
1357
+ ```
1358
+
1359
+ И импорт:
1360
+
1361
+ ```tsx
1362
+ import { FormField } from '@reformer/ui-kit'; // готовый wrapper
1363
+ // а не
1364
+ import { FormField } from '@reformer/cdk/form-field'; // headless, без Error
1365
+ ```
1366
+
1367
+ ## 31. 7. `onBlur` не срабатывает на `Select` / `RadioGroup`
1368
+
1369
+ **Симптом.** `touched`-флаг не появляется при выборе значения, поле «вечно»
1370
+ без подсветки ошибки.
1371
+
1372
+ **Причины.**
1373
+
1374
+ - `Select` (Radix) — `onBlur` пробрасывается через `onOpenChange(false)`, то
1375
+ есть срабатывает при закрытии дропдауна. Если пользователь кликает мимо без
1376
+ открытия — `onBlur` не сработает.
1377
+ - `RadioGroup` — `onBlur` приходит на каждый `<input>` радио. Если фокус
1378
+ перемещается между radio внутри группы, `blur`/`focus` чередуются. Это
1379
+ нормально для нативного поведения.
1380
+
1381
+ **Решение.** Для гарантированного `touched` используй `onChange` как trigger
1382
+ (ведь выбор — это явное взаимодействие):
1383
+
1384
+ ```tsx
1385
+ <Select
1386
+ value={form.city.value}
1387
+ onChange={(v) => {
1388
+ form.city.setValue(v);
1389
+ form.city.blur(); // принудительно помечаем touched
1390
+ }}
1391
+ options={CITIES}
1392
+ />
1393
+ ```
1394
+
1395
+ `FormField` делает это автоматически (читает `componentProps` из `FieldNode`).
1396
+ Проблема обычно возникает, если Select используется руками без `FormField`.
1397
+
1398
+ ## 32. 8. `cn` стирает мои классы или, наоборот, оставляет лишние
1399
+
1400
+ **Симптом.** Передал `className="px-2 py-1"` в компонент, ожидая, что
1401
+ overrideнет дефолт `px-3`, но получил оба.
1402
+
1403
+ **Причина.** В компоненте `className` подставлен **до** дефолтов. Внутри
1404
+ `Input`/`Button`/`Textarea` всё построено правильно: дефолты идут первыми,
1405
+ твой `className` — последним, и `tailwind-merge` оставляет последний
1406
+ конфликтующий класс.
1407
+
1408
+ Если это не работает — проверь, что пользовательский `className` действительно
1409
+ доходит до `cn(...)`. Например, в `ExampleCard` `className` идёт сразу после
1410
+ дефолтов; в кастомных обёртках убедись:
1411
+
1412
+ ```tsx
1413
+ <div className={cn('rounded-lg border p-4', className)} />
1414
+ // ↑ user override побеждает
1415
+ ```
1416
+
1417
+ ## 33. 9. `AsyncBoundary` не переключает состояние
1418
+
1419
+ **Симптом.** Пропал `loading`, но `children` не появились.
1420
+
1421
+ **Причины.**
1422
+
1423
+ - В `status` всё ещё `'loading'` или `'error'` — `AsyncBoundary` рендерит
1424
+ `children` только при `'ready'`. Проверь setState внутри `then(...)`.
1425
+ - Передан `LoadingComponent={<Spinner />}` (ReactNode) вместо
1426
+ `LoadingComponent={() => <Spinner />}` (`ComponentType`). При первом
1427
+ варианте TS не ругается, но рантайм может выдать «warning: invalid type».
1428
+
1429
+ **Решение:**
1430
+
1431
+ ```tsx
1432
+ <AsyncBoundary
1433
+ status={status}
1434
+ LoadingComponent={() => <Spinner />}
1435
+ ErrorComponent={() => <ErrorBanner />}
1436
+ >
1437
+ <Content />
1438
+ </AsyncBoundary>
1439
+ ```
1440
+
1441
+ ## 34. 10. `InputPassword`: иконка-«глаз» не появляется
1442
+
1443
+ **Симптом.** Прокинул `showToggle={true}` (или оставил дефолт), но в правом
1444
+ углу ничего нет.
1445
+
1446
+ **Причина.** Иконка появляется **только** если `value` непустой:
1447
+
1448
+ ```tsx
1449
+ const hasValue = Boolean(value);
1450
+ {
1451
+ showToggle && hasValue && <button>…</button>;
1452
+ }
1453
+ ```
1454
+
1455
+ **Решение.** Это работает as-designed: для пустого пароля смысла переключать
1456
+ видимость нет. Если нужно показывать иконку всегда — тонкая обёртка:
1457
+
1458
+ ```tsx
1459
+ <div className="relative">
1460
+ <InputPassword value={pwd} onChange={setPwd} showToggle={false} />
1461
+ <button onClick={...} className="absolute right-2 top-1/2">👁</button>
1462
+ </div>
1463
+ ```
1464
+
1465
+ ## 35. 11. JSON-renderer: `Select`-`options` хранятся в реестре, но в дропдауне пусто
1466
+
1467
+ **Симптом.** Регистрируется source `LOAN_TYPES` через `reg.source('LOAN_TYPES', list)`,
1468
+ в JSON-схеме `componentProps: { options: '$LOAN_TYPES' }`, но опции пустые.
1469
+
1470
+ **Причина.** `Select` ждёт `options: Array<{value, label, group?}>`, а из
1471
+ реестра приходит уже обработанная строкой ссылка `'$LOAN_TYPES'`. Нужен
1472
+ правильный синтаксис source-ссылки в реестре.
1473
+
1474
+ **Решение.** Проверь convention для source-ссылок в
1475
+ [`renderer-json/03-registry.md`](../../../reformer-renderer-json/docs/llms/03-registry.md).
1476
+ Внутри `Select` дальнейших магий нет — он просто читает `directOptions`
1477
+ один в один.
1478
+
1479
+ ## 36. See also
1480
+
1481
+ - [01-overview.md](01-overview.md) — список компонентов и их назначения.
1482
+ - [02-text-fields.md](02-text-fields.md), [03-choice-fields.md](03-choice-fields.md), [04-layout-and-buttons.md](04-layout-and-buttons.md) — детали по каждому компоненту.
1483
+ - [05-form-field-integration.md](05-form-field-integration.md) — `FormField` standalone и как `fieldWrapper`.
1484
+
1485
+ ## 37. Базовое использование
1486
+
1487
+ ```tsx
1488
+ import { useRef } from 'react';
1489
+ import { FormWizard, type FormWizardStep } from '@reformer/ui-kit/form-wizard';
1490
+ import type { FormWizardHandle } from '@reformer/cdk/form-wizard';
1491
+ import type { FormProxy } from '@reformer/core';
1492
+
1493
+ // Используйте `type`, не `interface`, для structural-совместимости с
1494
+ // FormFields constraint внутри FormWizard generic'а.
1495
+ type MyForm = {
1496
+ email: string;
1497
+ password: string;
1498
+ confirmation: boolean;
1499
+ };
1500
+
1501
+ const Step1: FC<{ control: FormProxy<MyForm> }> = ({ control }) => (
1502
+ <FormField control={control.email} />
1503
+ );
1504
+
1505
+ const Step2: FC<{ control: FormProxy<MyForm> }> = ({ control }) => (
1506
+ <FormField control={control.password} />
1507
+ );
1508
+
1509
+ const steps: FormWizardStep<MyForm>[] = [
1510
+ { number: 1, title: 'Email', icon: '📧', body: Step1 },
1511
+ { number: 2, title: 'Пароль', icon: '🔒', body: Step2 },
1512
+ { number: 3, title: 'Готово', icon: '✓', body: <ConfirmationView /> },
1513
+ ];
1514
+
1515
+ // ref типизируется явно типом формы; constraint `T extends Record<string, any>`
1516
+ // позволяет nullable-поля (`number | null`) внутри MyForm без TS-ошибок.
1517
+ const navRef = useRef<FormWizardHandle<MyForm>>(null);
1518
+
1519
+ <FormWizard
1520
+ ref={navRef}
1521
+ form={form}
1522
+ config={{ stepValidations: STEP_VALIDATIONS, fullValidation }}
1523
+ steps={steps}
1524
+ onSubmit={async (values) => api.submit(values)}
1525
+ />;
1526
+ ```
1527
+
1528
+ ## 38. Полиморфный `step.body`
1529
+
1530
+ `body` принимает три формы (runtime-discriminated):
1531
+
1532
+ | Форма | Когда использовать |
1533
+ | ------------------------------------------ | -------------------------------------------------- |
1534
+ | `ComponentType<{ control: FormProxy<T> }>` | TS-flow; FC получает `control={form}` через ui-kit |
1535
+ | `ReactNode` (готовый JSX) | Статический контент шага без необходимости control |
1536
+ | `RenderNode<T>` (RenderSchema subtree) | renderer-react / renderer-json flows |
1537
+
1538
+ Все три варианта работают в одном wizard'е — можно комбинировать.
1539
+
1540
+ ## 39. RenderNode body (renderer-react / renderer-json)
1541
+
1542
+ ```tsx
1543
+ const renderSchema = (path) => ({
1544
+ selector: 'wizard',
1545
+ component: FormWizard,
1546
+ componentProps: {
1547
+ form,
1548
+ config,
1549
+ onSubmit: handleSubmit,
1550
+ steps: [
1551
+ {
1552
+ number: 1,
1553
+ title: 'Кредит',
1554
+ icon: '💰',
1555
+ body: {
1556
+ component: Box,
1557
+ componentProps: { className: 'space-y-4' },
1558
+ children: [{ component: path.loanAmount }, { component: path.loanTerm }],
1559
+ },
1560
+ },
1561
+ ],
1562
+ },
1563
+ });
1564
+ ```
1565
+
1566
+ ui-kit FormWizard детектирует RenderNode (объект с `.component` без React-element-маркера) и оборачивает в `RenderNodeComponent` с `form={form}`.
1567
+
1568
+ ## 40. JSON
1569
+
1570
+ ```jsonc
1571
+ {
1572
+ "component": "FormWizard",
1573
+ "componentProps": {
1574
+ "config": "WIZARD_CONFIG",
1575
+ "onSubmit": "handleSubmit",
1576
+ "steps": [
1577
+ {
1578
+ "number": 1,
1579
+ "title": "Кредит",
1580
+ "icon": "💰",
1581
+ "body": {
1582
+ "component": "Box",
1583
+ "children": [{ "model": "loanAmount" }, { "model": "loanTerm" }],
1584
+ },
1585
+ },
1586
+ ],
1587
+ },
1588
+ }
1589
+ ```
1590
+
1591
+ `body` — обычный JsonNode → конвертер renderer-json превращает в RenderNode → ui-kit FormWizard рендерит.
1592
+
1593
+ ## 41. Compound API
1594
+
1595
+ `FormWizard.Indicator/Step/Actions/Progress` — re-export headless слотов из CDK для consumer-ов, которым нужен полностью кастомный layout (например, indicator поверх кастомного header'а).
1596
+
1597
+ ```tsx
1598
+ <FormWizard.Indicator steps={...}>
1599
+ {(props) => <CustomIndicator {...props} />}
1600
+ </FormWizard.Indicator>
1601
+ ```
1602
+
1603
+ ## 42. Ref / handle
1604
+
1605
+ ```tsx
1606
+ const wizardRef = useRef<FormWizardHandle<MyForm>>(null);
1607
+
1608
+ <FormWizard ref={wizardRef} ... />
1609
+
1610
+ // Программная навигация:
1611
+ wizardRef.current?.goToStep(2);
1612
+ wizardRef.current?.submit(handleSubmit);
1613
+ ```
1614
+
1615
+ ## 43. Базовое использование (TS-flow)
1616
+
1617
+ ```tsx
1618
+ import { FormArraySection } from '@reformer/ui-kit/form-array';
1619
+ import type { FormProxy } from '@reformer/core';
1620
+
1621
+ // ВАЖНО: используйте `type`, не `interface` — иначе тип элемента не
1622
+ // удовлетворяет constraint `extends FormFields` (FormFields требует
1623
+ // implicit index signature, который interface не даёт).
1624
+ type Property = {
1625
+ type: 'apartment' | 'house' | 'land';
1626
+ description: string;
1627
+ estimatedValue: number;
1628
+ };
1629
+
1630
+ const PropertyForm: FC<{ control: FormProxy<Property> }> = ({ control }) => (
1631
+ <Section className="space-y-3">
1632
+ <FormField control={control.type} />
1633
+ <FormField control={control.description} />
1634
+ <FormField control={control.estimatedValue} />
1635
+ </Section>
1636
+ );
1637
+
1638
+ // Type-safe initialValue — generic выводится из control:
1639
+ <FormArraySection
1640
+ control={form.properties} // FormArrayProxy<Property>
1641
+ itemComponent={PropertyForm}
1642
+ title="Имущество"
1643
+ addButtonLabel="+ Добавить имущество"
1644
+ emptyMessage="Нажмите «Добавить имущество» для добавления записи"
1645
+ hasItems={hasProperty}
1646
+ initialValue={{ type: 'apartment', description: '', estimatedValue: 0 }}
1647
+ />
1648
+
1649
+ // Если TS не выводит generic из union-типа control — укажите явно:
1650
+ <FormArraySection<Property>
1651
+ control={form.properties}
1652
+ itemComponent={PropertyForm}
1653
+ initialValue={createProperty()} // Partial<Property> — checked
1654
+ />
1655
+ ```
1656
+
1657
+ `initialValue` имеет тип `Partial<T>`, где `T` — тип элемента массива.
1658
+ Передавайте plain-objects по форме элемента, **не** FieldConfig-объекты.
1659
+
1660
+ ## 44. Renderer-react RenderSchema
1661
+
1662
+ `control` принимает `FieldPathNode` (`path.<arrayField>`) — резолвится автоматически через `FieldPathNavigator`.
1663
+
1664
+ ```tsx
1665
+ const renderSchema = (path) => ({
1666
+ selector: 'properties-section',
1667
+ component: FormArraySection,
1668
+ componentProps: {
1669
+ control: path.properties, // FieldPath → ArrayNode
1670
+ itemComponent: PropertyForm, // FC напрямую
1671
+ title: 'Имущество',
1672
+ addButtonLabel: '+ Добавить имущество',
1673
+ },
1674
+ });
1675
+ ```
1676
+
1677
+ ui-kit FormArraySection маркирован `__selfManagedChildren = true` — родитель-renderer пробрасывает `form` без рекурсии. Резолв `FieldPathNode → ArrayNode` происходит внутри.
1678
+
1679
+ ## 45. JSON (renderer-json)
1680
+
1681
+ Два варианта `itemComponent`:
1682
+
1683
+ ### Вариант 1: registry-name (FC зарегистрирован как container)
1684
+
1685
+ ```ts
1686
+ // registry.ts
1687
+ defineRegistry((reg) => {
1688
+ reg.container('FormArraySection', FormArraySection);
1689
+ reg.container('PropertyForm', PropertyForm);
1690
+ });
1691
+ ```
1692
+
1693
+ ```jsonc
1694
+ {
1695
+ "component": "FormArraySection",
1696
+ "componentProps": {
1697
+ "control": "properties", // строка → FieldPath
1698
+ "itemComponent": "PropertyForm", // string → registry lookup → FC
1699
+ "title": "Имущество",
1700
+ "addButtonLabel": "+ Добавить имущество",
1701
+ },
1702
+ }
1703
+ ```
1704
+
1705
+ Конвертер видит string в `*Component` слоте → ищет в registry → подставляет FC.
1706
+
1707
+ ### Вариант 2: inline `$template`
1708
+
1709
+ ```jsonc
1710
+ {
1711
+ "component": "FormArraySection",
1712
+ "componentProps": {
1713
+ "control": "properties",
1714
+ "itemComponent": {
1715
+ "$template": {
1716
+ "component": "Section",
1717
+ "componentProps": { "className": "space-y-3" },
1718
+ "children": [
1719
+ {
1720
+ "model": "type",
1721
+ "component": "Select",
1722
+ "componentProps": { "label": "Тип", "options": "PROPERTY_TYPES" },
1723
+ },
1724
+ { "model": "description", "component": "Textarea" },
1725
+ {
1726
+ "model": "estimatedValue",
1727
+ "component": "Input",
1728
+ "componentProps": { "type": "number" },
1729
+ },
1730
+ ],
1731
+ },
1732
+ },
1733
+ "title": "Имущество",
1734
+ },
1735
+ }
1736
+ ```
1737
+
1738
+ Конвертер обнаруживает `$template`, конвертирует JsonNode шаблона в RenderNode (один раз), и оборачивает в FC `({ control }) => <RenderNodeComponent node={renderNode} form={control} />`. Снаружи это обычный FC.
1739
+
1740
+ ## 46. Props (полный список)
1741
+
1742
+ | Prop | Type | Default | Описание |
1743
+ | -------------------- | ---------------------------------------------------- | -------------- | -------------------------------------- |
1744
+ | `control` | `FormArrayProxy<T> \| ArrayNode<T> \| FieldPathNode` | required | Массив для управления |
1745
+ | `itemComponent` | `ComponentType<{ control: FormProxy<T> }>` | required | FC для рендера каждого item |
1746
+ | `title` | `string` | — | Заголовок секции (h3) |
1747
+ | `itemLabel` | `string \| (control, index) => string` | — | Метка над каждым item |
1748
+ | `addButtonLabel` | `string` | `'+ Добавить'` | Текст кнопки добавления |
1749
+ | `removeButtonLabel` | `string` | `'Удалить'` | Текст кнопки удаления |
1750
+ | `emptyMessage` | `string` | — | Сообщение при пустом массиве |
1751
+ | `emptyMessageHint` | `string` | — | Подсказка под emptyMessage |
1752
+ | `hasItems` | `boolean` | — | `false` → секция полностью скрыта |
1753
+ | `initialValue` | `Partial<FormFields>` | — | Plain-leaf значения для новых items |
1754
+ | `maxItems` | `number` | — | Максимум items (AddButton отключается) |
1755
+ | `showRemoveOnSingle` | `boolean` | `false` | Показывать «Удалить» при одном item |
1756
+
1757
+ ## 47. Critical: `initialValue` — PLAIN LEAVES ONLY
1758
+
1759
+ ```tsx
1760
+ // ❌ silent corruption (FieldConfig as value)
1761
+ initialValue={{ type: { value: 'apartment', component: Select }, ... }}
1762
+
1763
+ // ✅ plain primitives matching item shape
1764
+ initialValue={{ type: 'apartment', description: '', estimatedValue: 0 }}
1765
+ ```
1766
+
1767
+ FieldConfig попадает в значение поля → Textarea рендерит `[object Object]`, Checkbox флипается в `true`. Compiler/тесты не ловят.
1768
+
1769
+ ## 48. API Reference
1770
+
1771
+ _Auto-generated from JSDoc on public exports._
1772
+
1773
+ ### AsyncBoundary
1774
+
1775
+ **Kind:** `function`
1776
+
1777
+ Контейнер с тремя состояниями (`loading`/`error`/`ready`). В состоянии `ready`
1778
+ отображает `children`. Для loading/error используются переданные slot-компоненты
1779
+ (`ComponentType`, без props — для кастомизации передай тонкую обёртку).
1780
+
1781
+ Это не Suspense-boundary: ничего не throw'ится, состоянием управляешь сам.
1782
+
1783
+ **Signature:**
1784
+ ```typescript
1785
+ export function AsyncBoundary({
1786
+ status,
1787
+ LoadingComponent,
1788
+ ErrorComponent,
1789
+ children,
1790
+ }: AsyncBoundaryProps): ReactNode
1791
+ ```
1792
+
1793
+ **Examples:**
1794
+
1795
+ Загрузка списка с обработкой ошибки
1796
+ ```tsx
1797
+ import { useEffect, useState } from 'react';
1798
+ import { AsyncBoundary, type AsyncStatus } from '@reformer/ui-kit';
1799
+
1800
+ function CountriesPage() {
1801
+ const [status, setStatus] = useState<AsyncStatus>('loading');
1802
+ const [countries, setCountries] = useState<string[]>([]);
1803
+
1804
+ useEffect(() => {
1805
+ fetch('/api/countries')
1806
+ .then((r) => r.json())
1807
+ .then((d) => { setCountries(d); setStatus('ready'); })
1808
+ .catch(() => setStatus('error'));
1809
+ }, []);
1810
+
1811
+ return (
1812
+ <AsyncBoundary
1813
+ status={status}
1814
+ LoadingComponent={() => <p>Загружаем страны...</p>}
1815
+ ErrorComponent={() => <p>Ошибка загрузки</p>}
1816
+ >
1817
+ <ul>{countries.map((c) => <li key={c}>{c}</li>)}</ul>
1818
+ </AsyncBoundary>
1819
+ );
1820
+ }
1821
+ ```
1822
+
1823
+ Внутри RenderSchema (статус подставляется через `patchProps`)
1824
+ ```tsx
1825
+ import { AsyncBoundary } from '@reformer/ui-kit';
1826
+
1827
+ createRenderSchema((path) => ({
1828
+ selector: 'data-boundary',
1829
+ component: AsyncBoundary,
1830
+ componentProps: { status: 'loading', LoadingComponent: Spinner },
1831
+ children: [...],
1832
+ }));
1833
+ // позже: schema.node('data-boundary').patchProps({ status: 'ready' });
1834
+ ```
1835
+
1836
+ _Source: src/components/ui/async-boundary.tsx_
1837
+
1838
+ ### AsyncBoundaryProps
1839
+
1840
+ **Kind:** `interface`
1841
+
1842
+ Props компонента {@link AsyncBoundary}.
1843
+
1844
+ **Signature:**
1845
+ ```typescript
1846
+ export interface AsyncBoundaryProps {
1847
+ /** Текущее состояние асинхронной операции. Управляется снаружи. */
1848
+ status: AsyncStatus;
1849
+ /** Компонент, рендерящийся при `status === 'loading'`. Без props. Если не передан — `null`. */
1850
+ LoadingComponent?: ComponentType;
1851
+ /** Компонент, рендерящийся при `status === 'error'`. Без props. Если не передан — `null`. */
1852
+ ErrorComponent?: ComponentType;
1853
+ /** Контент, рендерящийся при `status === 'ready'`. */
1854
+ children?: ReactNode;
1855
+ }
1856
+ ```
1857
+
1858
+ _Source: src/components/ui/async-boundary.tsx_
1859
+
1860
+ ### AsyncStatus
1861
+
1862
+ **Kind:** `type`
1863
+
1864
+ Состояние асинхронной операции, ожидаемое {@link AsyncBoundary}.
1865
+
1866
+ **Signature:**
1867
+ ```typescript
1868
+ export type AsyncStatus = 'loading' | 'error' | 'ready';
1869
+ ```
1870
+
1871
+ _Source: src/components/ui/async-boundary.tsx_
1872
+
1873
+ ### Box
1874
+
1875
+ **Kind:** `function`
1876
+
1877
+ Box - базовый контейнер-обёртка.
1878
+
1879
+ Простой `<div>` для группировки элементов в `RenderSchema`. Используйте
1880
+ `className` для настройки layout через atomic CSS (Tailwind).
1881
+
1882
+ **Signature:**
1883
+ ```typescript
1884
+ export function Box({ className, children }: BoxProps): ReactNode
1885
+ ```
1886
+
1887
+ **Examples:**
1888
+
1889
+ Вертикальный список полей в RenderSchema
1890
+ ```typescript
1891
+ {
1892
+ component: Box,
1893
+ componentProps: { className: 'flex flex-col gap-4' },
1894
+ children: [
1895
+ { component: path.email },
1896
+ { component: path.password },
1897
+ ],
1898
+ }
1899
+ ```
1900
+
1901
+ Двухколоночная сетка
1902
+ ```typescript
1903
+ {
1904
+ component: Box,
1905
+ componentProps: { className: 'grid grid-cols-2 gap-4' },
1906
+ children: [
1907
+ { component: path.firstName },
1908
+ { component: path.lastName },
1909
+ ],
1910
+ }
1911
+ ```
1912
+
1913
+ _Source: src/components/ui/box.tsx_
1914
+
1915
+ ### BoxProps
1916
+
1917
+ **Kind:** `interface`
1918
+
1919
+ Props компонента Box
1920
+
1921
+ **Signature:**
1922
+ ```typescript
1923
+ export interface BoxProps {
1924
+ /** CSS класс для стилизации */
1925
+ className?: string;
1926
+ /** Дочерние элементы */
1927
+ children?: ReactNode;
1928
+ }
1929
+ ```
1930
+
1931
+ _Source: src/components/ui/box.tsx_
1932
+
1933
+ ### Button
1934
+
1935
+ **Kind:** `function`
1936
+
1937
+ Базовая кнопка на shadcn/Radix `Slot`. Поддерживает 6 вариантов
1938
+ (`default`/`destructive`/`outline`/`secondary`/`ghost`/`link`), 6 размеров
1939
+ (`default`/`sm`/`lg`/`icon`/`icon-sm`/`icon-lg`) и `asChild` для замены
1940
+ корневого DOM-узла на дочерний элемент (например, `<Link>` из роутера).
1941
+
1942
+ **Signature:**
1943
+ ```typescript
1944
+ function Button({
1945
+ className,
1946
+ variant,
1947
+ size,
1948
+ asChild = false,
1949
+ ...props
1950
+ }: React.ComponentProps<'button'> &
1951
+ VariantProps<typeof buttonVariants> & {
1952
+ asChild?: boolean;
1953
+ })
1954
+ ```
1955
+
1956
+ **Examples:**
1957
+
1958
+ Variants matrix (для design-system документации)
1959
+ ```tsx
1960
+ import { Button } from '@reformer/ui-kit';
1961
+
1962
+ {(['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'] as const).map(
1963
+ (v) => <Button key={v} variant={v}>{v}</Button>
1964
+ )}
1965
+ ```
1966
+
1967
+ asChild + react-router Link (стили кнопки на анкоре)
1968
+ ```tsx
1969
+ import { Link } from 'react-router-dom';
1970
+ import { Button } from '@reformer/ui-kit';
1971
+
1972
+ <Button asChild variant="outline" size="lg">
1973
+ <Link to="/dashboard">Открыть дашборд</Link>
1974
+ </Button>
1975
+ ```
1976
+
1977
+ _Source: src/components/ui/button.tsx_
1978
+
1979
+ ### Checkbox
1980
+
1981
+ **Kind:** `const`
1982
+
1983
+ Чекбокс с опциональной подписью справа. Контракт `value`/`onChange` —
1984
+ `boolean`, не строка.
1985
+
1986
+ `FormField` из `@reformer/ui-kit` детектит `Checkbox` и не рендерит верхний
1987
+ `Label`, чтобы не дублировать подпись.
1988
+
1989
+ **Signature:**
1990
+ ```typescript
1991
+ const Checkbox
1992
+ ```
1993
+
1994
+ **Examples:**
1995
+
1996
+ Согласие с условиями
1997
+ ```tsx
1998
+ import { Checkbox } from '@reformer/ui-kit';
1999
+
2000
+ <Checkbox
2001
+ value={agree}
2002
+ onChange={setAgree}
2003
+ label="Согласен с условиями обработки персональных данных"
2004
+ />
2005
+ ```
2006
+
2007
+ Без подписи (label справа добавляется снаружи)
2008
+ ```tsx
2009
+ import { Checkbox } from '@reformer/ui-kit';
2010
+
2011
+ <div className="flex items-center gap-2">
2012
+ <Checkbox value={hasMortgage} onChange={setHasMortgage} />
2013
+ <span>У меня уже есть ипотека</span>
2014
+ </div>
2015
+ ```
2016
+
2017
+ _Source: src/components/ui/checkbox.tsx_
2018
+
2019
+ ### CheckboxProps
2020
+
2021
+ **Kind:** `interface`
2022
+
2023
+ Props компонента {@link Checkbox}.
2024
+
2025
+ **Signature:**
2026
+ ```typescript
2027
+ export interface CheckboxProps extends Omit<
2028
+ React.InputHTMLAttributes<HTMLInputElement>,
2029
+ 'value' | 'onChange' | 'type'
2030
+ > {
2031
+ /** Дополнительный CSS-класс для самого input. */
2032
+ className?: string;
2033
+ /** Состояние чекбокса. `undefined` рендерится как `false`. */
2034
+ value?: boolean;
2035
+ /** Вызывается при изменении. Получает `event.target.checked`. */
2036
+ onChange?: (value: boolean) => void;
2037
+ /** Срабатывает при потере фокуса. */
2038
+ onBlur?: () => void;
2039
+ /** Подпись справа от чекбокса. Если опущена — рендерится только сам контрол. */
2040
+ label?: string;
2041
+ /** Блокирует переключение. */
2042
+ disabled?: boolean;
2043
+ /** Test-id для e2e (используется как `data-testid` на input). */
2044
+ 'data-testid'?: string;
2045
+ }
2046
+ ```
2047
+
2048
+ _Source: src/components/ui/checkbox.tsx_
2049
+
2050
+ ### cn
2051
+
2052
+ **Kind:** `function`
2053
+
2054
+ Объединяет CSS-классы (`clsx` + `tailwind-merge`). Удобно для условных
2055
+ классов с разрешением конфликтов Tailwind: при конфликтующих классах из
2056
+ одной семьи (`px-2` и `px-4`) побеждает последний.
2057
+
2058
+ **Signature:**
2059
+ ```typescript
2060
+ export function cn(...inputs: ClassValue[])
2061
+ ```
2062
+
2063
+ **Examples:**
2064
+
2065
+ Условные классы (последний `px-*` побеждает)
2066
+ ```typescript
2067
+ import { cn } from '@reformer/ui-kit';
2068
+
2069
+ cn('px-2 py-1', isActive && 'bg-blue-500', 'px-4');
2070
+ // → 'py-1 bg-blue-500 px-4'
2071
+ ```
2072
+
2073
+ Override дефолтных стилей в forwardRef-компоненте
2074
+ ```tsx
2075
+ import * as React from 'react';
2076
+ import { cn } from '@reformer/ui-kit';
2077
+
2078
+ const Card = React.forwardRef<HTMLDivElement, { className?: string }>(
2079
+ ({ className, ...props }, ref) => (
2080
+ <div ref={ref} className={cn('rounded-lg border p-4', className)} {...props} />
2081
+ )
2082
+ );
2083
+
2084
+ <Card className="p-8" /> // p-4 затёрт пользовательским p-8
2085
+ ```
2086
+
2087
+ _Source: src/lib/utils.ts_
2088
+
2089
+ ### Collapsible
2090
+
2091
+ **Kind:** `function`
2092
+
2093
+ Collapsible - сворачиваемая секция формы.
2094
+
2095
+ Заголовок-кнопка переключает видимость `children`. Состояние локальное
2096
+ (`useState`), внешний control пока не поддерживается — для управляемого
2097
+ варианта используй `RenderSchema.node('selector').setHidden(true)` поверх
2098
+ обычного `Box`.
2099
+
2100
+ **Signature:**
2101
+ ```typescript
2102
+ export function Collapsible({
2103
+ title,
2104
+ defaultOpen = true,
2105
+ className,
2106
+ titleClassName,
2107
+ contentClassName,
2108
+ children,
2109
+ }: CollapsibleProps): ReactNode
2110
+ ```
2111
+
2112
+ **Examples:**
2113
+
2114
+ Свёрнута по умолчанию
2115
+ ```typescript
2116
+ {
2117
+ component: Collapsible,
2118
+ componentProps: {
2119
+ title: 'Дополнительные параметры',
2120
+ defaultOpen: false,
2121
+ className: 'border rounded p-4',
2122
+ titleClassName: 'font-semibold w-full text-left',
2123
+ },
2124
+ children: [
2125
+ { component: path.notes },
2126
+ { component: path.tags },
2127
+ ],
2128
+ }
2129
+ ```
2130
+
2131
+ Развёрнута, со специальным фоном контента
2132
+ ```typescript
2133
+ {
2134
+ component: Collapsible,
2135
+ componentProps: {
2136
+ title: 'Адрес доставки',
2137
+ defaultOpen: true,
2138
+ contentClassName: 'mt-2 bg-gray-50 p-3 rounded',
2139
+ },
2140
+ children: [
2141
+ { component: path.deliveryAddress },
2142
+ ],
2143
+ }
2144
+ ```
2145
+
2146
+ _Source: src/components/ui/collapsible.tsx_
2147
+
2148
+ ### CollapsibleProps
2149
+
2150
+ **Kind:** `interface`
2151
+
2152
+ Props компонента Collapsible
2153
+
2154
+ **Signature:**
2155
+ ```typescript
2156
+ export interface CollapsibleProps {
2157
+ /** Заголовок секции */
2158
+ title: string;
2159
+ /** Начальное состояние (развёрнуто по умолчанию) */
2160
+ defaultOpen?: boolean;
2161
+ /** CSS класс для контейнера */
2162
+ className?: string;
2163
+ /** CSS класс для заголовка */
2164
+ titleClassName?: string;
2165
+ /** CSS класс для контента */
2166
+ contentClassName?: string;
2167
+ /** Дочерние элементы */
2168
+ children?: ReactNode;
2169
+ }
2170
+ ```
2171
+
2172
+ _Source: src/components/ui/collapsible.tsx_
2173
+
2174
+ ### ErrorState
2175
+
2176
+ **Kind:** `function`
2177
+
2178
+ **Signature:**
2179
+ ```typescript
2180
+ export function ErrorState({
2181
+ error,
2182
+ title = 'Ошибка загрузки',
2183
+ onRetry,
2184
+ retryLabel = 'Повторить',
2185
+ className,
2186
+ }: ErrorStateProps): ReactNode
2187
+ ```
2188
+
2189
+ _Source: src/components/state/error-state.tsx_
2190
+
2191
+ ### ExampleCard
2192
+
2193
+ **Kind:** `function`
2194
+
2195
+ Карточка-обёртка для демонстрации компонентов в playground: заголовок,
2196
+ описание, область с примером и кнопка переключения исходного кода с
2197
+ copy-to-clipboard.
2198
+
2199
+ Утилита для playground/документации, не для продакшена.
2200
+
2201
+ **Signature:**
2202
+ ```typescript
2203
+ export function ExampleCard({
2204
+ title,
2205
+ description,
2206
+ children,
2207
+ code,
2208
+ className,
2209
+ bgColor = 'bg-white',
2210
+ }: ExampleCardProps)
2211
+ ```
2212
+
2213
+ **Examples:**
2214
+
2215
+ Базовое использование
2216
+ ```tsx
2217
+ import { ExampleCard, Input } from '@reformer/ui-kit';
2218
+ import { useState } from 'react';
2219
+
2220
+ function Demo() {
2221
+ const [v, setV] = useState<string | null>(null);
2222
+ return (
2223
+ <ExampleCard
2224
+ title="Input — базовый"
2225
+ description="Однострочное поле с placeholder"
2226
+ code={`<Input value={v} onChange={setV} placeholder="Email" />`}
2227
+ >
2228
+ <Input value={v} onChange={setV} placeholder="Email" />
2229
+ </ExampleCard>
2230
+ );
2231
+ }
2232
+ ```
2233
+
2234
+ С кастомным фоном (для подсветки группы)
2235
+ ```tsx
2236
+ import { ExampleCard, Button } from '@reformer/ui-kit';
2237
+
2238
+ <ExampleCard
2239
+ title="Destructive button"
2240
+ description="Кнопка опасного действия"
2241
+ bgColor="bg-red-50"
2242
+ code={`<Button variant="destructive">Delete</Button>`}
2243
+ >
2244
+ <Button variant="destructive">Delete</Button>
2245
+ </ExampleCard>
2246
+ ```
2247
+
2248
+ _Source: src/components/ui/example-card.tsx_
2249
+
2250
+ ### ExampleCardProps
2251
+
2252
+ **Kind:** `interface`
2253
+
2254
+ Props компонента {@link ExampleCard}.
2255
+
2256
+ **Signature:**
2257
+ ```typescript
2258
+ export interface ExampleCardProps {
2259
+ /** Заголовок карточки (обязательный). */
2260
+ title: string;
2261
+ /** Описание под заголовком. */
2262
+ description?: string;
2263
+ /** Контент примера, отображаемый в режиме «пример». */
2264
+ children: React.ReactNode;
2265
+ /** Текст исходного кода, копируемый в clipboard в режиме «код». */
2266
+ code: string;
2267
+ /** Дополнительный CSS-класс контейнера. */
2268
+ className?: string;
2269
+ /** Tailwind-класс фона карточки. По умолчанию `'bg-white'`. */
2270
+ bgColor?: string;
2271
+ }
2272
+ ```
2273
+
2274
+ _Source: src/components/ui/example-card.tsx_
2275
+
2276
+ ### FormArraySection
2277
+
2278
+ **Kind:** `function`
2279
+
2280
+ **Signature:**
2281
+ ```typescript
2282
+ export function FormArraySection<T extends FormFields>({
2283
+ control,
2284
+ itemComponent: ItemComponent,
2285
+ title,
2286
+ itemLabel,
2287
+ addButtonLabel = '+ Добавить',
2288
+ removeButtonLabel = 'Удалить',
2289
+ emptyMessage,
2290
+ emptyMessageHint,
2291
+ hasItems,
2292
+ initialValue,
2293
+ showRemoveOnSingle = false,
2294
+ maxItems,
2295
+ className = 'space-y-3 mt-2',
2296
+ cardClassName = 'mb-4 p-4 bg-white rounded border',
2297
+ form,
2298
+ }: FormArraySectionProps<T>): ReactNode
2299
+ ```
2300
+
2301
+ _Source: src/components/form-array/form-array-section.tsx_
2302
+
2303
+ ### FormArraySectionProps
2304
+
2305
+ **Kind:** `interface`
2306
+
2307
+ **Signature:**
2308
+ ```typescript
2309
+ export interface FormArraySectionProps<T extends FormFields> {
2310
+ /**
2311
+ * Резолвится автоматически: уже-резолвленный ArrayNode/FormArrayProxy ИЛИ
2312
+ * FieldPathNode (path.<arrayField>) — в этом случае через `form` + navigator.
2313
+ */
2314
+ control: FormArrayProxy<T> | ArrayNode<T> | FieldPathNode<unknown, unknown> | undefined;
2315
+
2316
+ /** React FC получает `control: FormProxy<T>` для каждого элемента. */
2317
+ itemComponent: ComponentType<{ control: FormProxy<T> }>;
2318
+
2319
+ /** Заголовок секции (рендерится h3). */
2320
+ title?: string;
2321
+
2322
+ /** Метка для каждого item — строка-префикс или функция. */
2323
+ itemLabel?: string | ((control: FormProxy<T>, index: number) => string);
2324
+
2325
+ /** Текст кнопки добавления. По умолчанию `'+ Добавить'`. */
2326
+ addButtonLabel?: string;
2327
+
2328
+ /** Текст кнопки удаления. По умолчанию `'Удалить'`. */
2329
+ removeButtonLabel?: string;
2330
+
2331
+ /** Сообщение пустого состояния. */
2332
+ emptyMessage?: string;
2333
+
2334
+ /** Подсказка под пустым состоянием. */
2335
+ emptyMessageHint?: string;
2336
+
2337
+ /**
2338
+ * Условие видимости секции. Если `false` — секция полностью скрыта.
2339
+ * Удобно для toggle-чекбоксов вида «У меня есть имущество».
2340
+ */
2341
+ hasItems?: boolean;
2342
+
2343
+ /**
2344
+ * Plain-leaf значения для новых items (передаётся в `FormArray.AddButton`).
2345
+ * НЕ FieldConfig — только примитивы по форме item-типа `T`.
2346
+ *
2347
+ * Тип `Partial<T>` — TS проверит, что initialValue совместим с типом элемента.
2348
+ * Передавайте generic явно для лучшей type-safety:
2349
+ * `<FormArraySection<PropertyItem> initialValue={createPropertyItem()} ...>`.
2350
+ */
2351
+ initialValue?: Partial<T>;
2352
+
2353
+ /** Показывать «Удалить» когда остался один элемент. По умолчанию `false`. */
2354
+ showRemoveOnSingle?: boolean;
2355
+
2356
+ /** Максимум items — AddButton отключается при достижении. */
2357
+ maxItems?: number;
2358
+
2359
+ /** Внешний className секции. */
2360
+ className?: string;
2361
+
2362
+ /** Класс card-обёртки каждого item. */
2363
+ cardClassName?: string;
2364
+
2365
+ /**
2366
+ * FormProxy. Авто-инъектится `RenderNodeComponent` через
2367
+ * `__selfManagedChildren` маркер. Передавать вручную — только при
2368
+ * использовании вне стандартного render-tree.
2369
+ */
2370
+ form?: FormProxy<unknown>;
2371
+
2372
+ /**
2373
+ * Field wrapper для дочерних полей. Авто-инъектится `RenderNodeComponent`;
2374
+ * переопределить для использования другого wrapper в этой секции.
2375
+ */
2376
+ fieldWrapper?: ComponentType<FieldWrapperProps>;
2377
+ }
2378
+ ```
2379
+
2380
+ _Source: src/components/form-array/form-array-section.tsx_
2381
+
2382
+ ### FormField
2383
+
2384
+ **Kind:** `const`
2385
+
2386
+ Готовый wrapper поля: автоматически рендерит `Label` → `Control` → `Error`
2387
+ из `@reformer/cdk/form-field`. Подключается напрямую `<FormField control=... />`
2388
+ или как `fieldWrapper` для `FormRenderer`.
2389
+
2390
+ Особенности:
2391
+ - Для `Checkbox` верхний `Label` не рендерится (label идёт справа от контрола).
2392
+ - При `pending = true` (async-валидация) под полем показывается «Проверка...».
2393
+ - Обёрнут в `React.memo` со сравнением по ссылке `control` — критично для
2394
+ производительности больших форм.
2395
+
2396
+ **Signature:**
2397
+ ```typescript
2398
+ export const FormField
2399
+ ```
2400
+
2401
+ **Examples:**
2402
+
2403
+ Standalone в обычной форме
2404
+ ```tsx
2405
+ import { useMemo } from 'react';
2406
+ import { createForm, type FormSchema } from '@reformer/core';
2407
+ import { Button, FormField, Input } from '@reformer/ui-kit';
2408
+
2409
+ function RegistrationPage() {
2410
+ const form = useMemo(
2411
+ () => createForm<FormSchema<{ email: string }>>({
2412
+ email: { component: Input, componentProps: { label: 'Email' } },
2413
+ }),
2414
+ []
2415
+ );
2416
+ return (
2417
+ <form>
2418
+ <FormField control={form.email} testId="email" />
2419
+ <Button type="submit">OK</Button>
2420
+ </form>
2421
+ );
2422
+ }
2423
+ ```
2424
+
2425
+ В качестве `fieldWrapper` для FormRenderer
2426
+ ```tsx
2427
+ import { FormRenderer, createRenderSchema } from '@reformer/renderer-react';
2428
+ import { FormField } from '@reformer/ui-kit';
2429
+
2430
+ const schema = createRenderSchema<MyForm>((path) => ({
2431
+ component: 'Box',
2432
+ children: [{ component: path.email }, { component: path.phone }],
2433
+ }));
2434
+
2435
+ <FormRenderer render={schema} settings={{ fieldWrapper: FormField }} />
2436
+ ```
2437
+
2438
+ _Source: src/components/ui/form-field.tsx_
2439
+
2440
+ ### FormFieldProps
2441
+
2442
+ **Kind:** `interface`
2443
+
2444
+ Props компонента {@link FormField}.
2445
+
2446
+ **Signature:**
2447
+ ```typescript
2448
+ export interface FormFieldProps {
2449
+ /**
2450
+ * Поле формы. Из него берутся `component` (тип контрола), `componentProps`,
2451
+ * `value`, `error`, `pending`, `setValue`, `blur`. Контрол инстанцируется
2452
+ * автоматически через `CdkFormField.Control`.
2453
+ */
2454
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2455
+ control: FieldNode<any>;
2456
+ /** Дополнительный CSS-класс для корневой `<div>`-обёртки. */
2457
+ className?: string;
2458
+ /**
2459
+ * Префикс для `data-testid` (`field-<id>`, `label-<id>`, `input-<id>`,
2460
+ * `error-<id>`). Если опущен — пытается взять `componentProps.testId`,
2461
+ * иначе подставляет `'unknown'`.
2462
+ */
2463
+ testId?: string;
2464
+ /**
2465
+ * Кастомный input — оборачивается в `CdkFormField.Control asChild`. Используется,
2466
+ * когда нужен нестандартный контрол (например, сторонняя маска), не
2467
+ * зарегистрированный в `control.component`.
2468
+ */
2469
+ children?: React.ReactNode;
2470
+ }
2471
+ ```
2472
+
2473
+ _Source: src/components/ui/form-field.tsx_
2474
+
2475
+ ### FormWizard
2476
+
2477
+ **Kind:** `const`
2478
+
2479
+ **Signature:**
2480
+ ```typescript
2481
+ const FormWizard
2482
+ ```
2483
+
2484
+ _Source: src/components/form-wizard/form-wizard.tsx_
2485
+
2486
+ ### FormWizardActions
2487
+
2488
+ **Kind:** `const`
2489
+
2490
+ **Signature:**
2491
+ ```typescript
2492
+ export const FormWizardActions: FC<FormWizardActionsProps>
2493
+ ```
2494
+
2495
+ _Source: src/components/form-wizard/form-wizard-actions.tsx_
2496
+
2497
+ ### FormWizardProgress
2498
+
2499
+ **Kind:** `const`
2500
+
2501
+ **Signature:**
2502
+ ```typescript
2503
+ export const FormWizardProgress: FC<FormWizardProgressProps>
2504
+ ```
2505
+
2506
+ _Source: src/components/form-wizard/form-wizard-progress.tsx_
2507
+
2508
+ ### FormWizardProps
2509
+
2510
+ **Kind:** `interface`
2511
+
2512
+ **Signature:**
2513
+ ```typescript
2514
+ export interface FormWizardProps<
2515
+ // Constraint синхронизирован с headless cdk (`Record<string, any>`) — это
2516
+ // снимает блокер инференции generic'а T в JSX, когда T содержит nullable-
2517
+ // числа (`number | null`). Constraint используется только для bound, not
2518
+ // for direct value access.
2519
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2520
+ T extends Record<string, any>,
2521
+ > extends FormWizardHeadlessProps<T> {
2522
+ className?: string;
2523
+ steps: FormWizardStep<T>[];
2524
+ onSubmit: HeadlessFormWizardActionsProps['onSubmit'];
2525
+ }
2526
+ ```
2527
+
2528
+ _Source: src/components/form-wizard/form-wizard.tsx_
2529
+
2530
+ ### FormWizardStep
2531
+
2532
+ **Kind:** `interface`
2533
+
2534
+ **Signature:**
2535
+ ```typescript
2536
+ export interface FormWizardStep<T> {
2537
+ /** Step number (1-based). */
2538
+ number: number;
2539
+ /** Step title shown in indicator. */
2540
+ title: string;
2541
+ /** Optional icon (string или ReactNode). Передаётся в headless Indicator. */
2542
+ icon?: string;
2543
+ /** Body — FC | ReactNode | RenderNode<T>. */
2544
+ body: FormWizardStepBody<T>;
2545
+ }
2546
+ ```
2547
+
2548
+ _Source: src/components/form-wizard/form-wizard.tsx_
2549
+
2550
+ ### FormWizardStepBody
2551
+
2552
+ **Kind:** `type`
2553
+
2554
+ Полиморфное тело шага.
2555
+
2556
+ - FC получает `control={form}`.
2557
+ - ReactNode рендерится напрямую (статический JSX).
2558
+ - RenderNode<T> рендерится через `<RenderNodeComponent>`.
2559
+
2560
+ **Signature:**
2561
+ ```typescript
2562
+ export type FormWizardStepBody<T> =
2563
+ | ComponentType<{ control: FormProxy<T> }>
2564
+ | ReactNode
2565
+ | RenderNode<T>;
2566
+ ```
2567
+
2568
+ _Source: src/components/form-wizard/form-wizard.tsx_
2569
+
2570
+ ### Input
2571
+
2572
+ **Kind:** `const`
2573
+
2574
+ Текстовое поле ввода. Контролируемый компонент с тривиальным API: `value`/`onChange`
2575
+ получает строку (или число для `type="number"`). Все нативные `<input>`-атрибуты
2576
+ (кроме `value`/`onChange`) прокидываются как есть.
2577
+
2578
+ **Signature:**
2579
+ ```typescript
2580
+ const Input
2581
+ ```
2582
+
2583
+ **Examples:**
2584
+
2585
+ Базовое строковое поле
2586
+ ```tsx
2587
+ import { Input } from '@reformer/ui-kit';
2588
+
2589
+ <Input value={email} onChange={setEmail} type="email" placeholder="you@example.com" />
2590
+ ```
2591
+
2592
+ Числовое поле с лимитом снизу
2593
+ ```tsx
2594
+ import { Input } from '@reformer/ui-kit';
2595
+
2596
+ // Пустой ввод даёт null. Отрицательные значения зажимаются к 0 из-за min={0}.
2597
+ <Input
2598
+ type="number"
2599
+ value={age}
2600
+ onChange={(v) => setAge(v as number | null)}
2601
+ min={0}
2602
+ placeholder="Возраст"
2603
+ />
2604
+ ```
2605
+
2606
+ _Source: src/components/ui/input.tsx_
2607
+
2608
+ ### InputMask
2609
+
2610
+ **Kind:** `const`
2611
+
2612
+ Текстовое поле с поддержкой простой маски-подсказки (через шаблон вида
2613
+ `'9'` для цифр). Маска показывается в `placeholder`, но автоматическая
2614
+ вставка литералов **не** выполняется — компонент просто помечает формат.
2615
+
2616
+ **Signature:**
2617
+ ```typescript
2618
+ const InputMask
2619
+ ```
2620
+
2621
+ **Examples:**
2622
+
2623
+ Маска для телефона
2624
+ ```tsx
2625
+ import { InputMask } from '@reformer/ui-kit';
2626
+
2627
+ <InputMask
2628
+ value={phone}
2629
+ onChange={setPhone}
2630
+ mask="+7 (999) 999-99-99"
2631
+ />
2632
+ ```
2633
+
2634
+ Маска для даты `DD.MM.YYYY`
2635
+ ```tsx
2636
+ import { InputMask } from '@reformer/ui-kit';
2637
+
2638
+ <InputMask
2639
+ value={birthDate}
2640
+ onChange={setBirthDate}
2641
+ mask="99.99.9999"
2642
+ placeholder="Дата рождения"
2643
+ />
2644
+ ```
2645
+
2646
+ _Source: src/components/ui/input-mask.tsx_
2647
+
2648
+ ### InputMaskProps
2649
+
2650
+ **Kind:** `interface`
2651
+
2652
+ Props компонента {@link InputMask}.
2653
+
2654
+ **Signature:**
2655
+ ```typescript
2656
+ export interface InputMaskProps extends Omit<
2657
+ React.InputHTMLAttributes<HTMLInputElement>,
2658
+ 'value' | 'onChange'
2659
+ > {
2660
+ /** Дополнительный CSS-класс. */
2661
+ className?: string;
2662
+ /** Текущее значение поля. `null`/`undefined` рендерится как пустое поле. */
2663
+ value?: string | null;
2664
+ /** Обработчик изменений. Пустая строка приводится к `null`. */
2665
+ onChange?: (value: string | null) => void;
2666
+ /** Срабатывает при потере фокуса. */
2667
+ onBlur?: () => void;
2668
+ /**
2669
+ * Шаблон маски: символ `'9'` означает «цифра», все остальные символы (`+`,
2670
+ * `-`, `(`, `)`, пробел, точка) — литералы и используются в `placeholder`.
2671
+ * Пример: `'+7 (999) 999-99-99'`.
2672
+ */
2673
+ mask?: string;
2674
+ /** Подсказка внутри поля. По умолчанию равна `mask` для подсветки формата. */
2675
+ placeholder?: string;
2676
+ /** Блокирует ввод и редактирование. */
2677
+ disabled?: boolean;
2678
+ }
2679
+ ```
2680
+
2681
+ _Source: src/components/ui/input-mask.tsx_
2682
+
2683
+ ### InputPassword
2684
+
2685
+ **Kind:** `const`
2686
+
2687
+ Поле ввода пароля с переключателем видимости (иконка eye/eye-off).
2688
+ Кнопка переключения показывается, когда `showToggle = true` (по умолчанию)
2689
+ и `value` непустой.
2690
+
2691
+ **Signature:**
2692
+ ```typescript
2693
+ const InputPassword
2694
+ ```
2695
+
2696
+ **Examples:**
2697
+
2698
+ С переключателем видимости
2699
+ ```tsx
2700
+ import { InputPassword } from '@reformer/ui-kit';
2701
+
2702
+ <InputPassword
2703
+ value={password}
2704
+ onChange={setPassword}
2705
+ placeholder="Пароль"
2706
+ />
2707
+ ```
2708
+
2709
+ Без переключателя (например, для подтверждения пароля)
2710
+ ```tsx
2711
+ import { InputPassword } from '@reformer/ui-kit';
2712
+
2713
+ <InputPassword
2714
+ value={confirmPassword}
2715
+ onChange={setConfirmPassword}
2716
+ placeholder="Повторите пароль"
2717
+ showToggle={false}
2718
+ />
2719
+ ```
2720
+
2721
+ _Source: src/components/ui/input-password.tsx_
2722
+
2723
+ ### InputPasswordProps
2724
+
2725
+ **Kind:** `interface`
2726
+
2727
+ Props компонента {@link InputPassword}.
2728
+
2729
+ **Signature:**
2730
+ ```typescript
2731
+ export interface InputPasswordProps extends Omit<
2732
+ React.InputHTMLAttributes<HTMLInputElement>,
2733
+ 'value' | 'onChange' | 'type'
2734
+ > {
2735
+ /** Дополнительный CSS-класс. */
2736
+ className?: string;
2737
+ /** Текущее значение пароля. `null`/`undefined` рендерится как пустое поле. */
2738
+ value?: string | null;
2739
+ /** Обработчик изменений. Пустая строка приводится к `null`. */
2740
+ onChange?: (value: string | null) => void;
2741
+ /** Срабатывает при потере фокуса. */
2742
+ onBlur?: () => void;
2743
+ /** Подсказка внутри поля. По умолчанию `'Password'`. */
2744
+ placeholder?: string;
2745
+ /** Блокирует ввод. */
2746
+ disabled?: boolean;
2747
+ /**
2748
+ * Показывать ли иконку переключения видимости (eye/eye-off). По умолчанию
2749
+ * `true`. Иконка появляется только когда `value` непустой.
2750
+ */
2751
+ showToggle?: boolean;
2752
+ }
2753
+ ```
2754
+
2755
+ _Source: src/components/ui/input-password.tsx_
2756
+
2757
+ ### InputProps
2758
+
2759
+ **Kind:** `interface`
2760
+
2761
+ Props компонента {@link Input}.
2762
+
2763
+ **Signature:**
2764
+ ```typescript
2765
+ export interface InputProps extends Omit<
2766
+ React.InputHTMLAttributes<HTMLInputElement>,
2767
+ 'value' | 'onChange'
2768
+ > {
2769
+ /** Дополнительный CSS-класс (мерджится с дефолтными Tailwind-классами через `tailwind-merge`). */
2770
+ className?: string;
2771
+ /**
2772
+ * Текущее значение поля. Для `type='number'` ожидается `number | null`,
2773
+ * для остальных — `string | null`. `null`/`undefined` рендерится как пустое поле.
2774
+ */
2775
+ value?: string | number | null;
2776
+ /**
2777
+ * Обработчик изменений. Получает уже распарсенное значение:
2778
+ * - для `type='number'` — `number` или `null` (для пустой строки),
2779
+ * - иначе — `string` или `null`.
2780
+ * `NaN` не прокидывается. При `min >= 0` отрицательные значения превращаются в `0`.
2781
+ */
2782
+ onChange?: (value: string | number | null) => void;
2783
+ /** Срабатывает при потере фокуса. Используется `FormField` для пометки `touched`. */
2784
+ onBlur?: () => void;
2785
+ /** HTML-тип input. Для `'number'` включается специальный парсинг значения. */
2786
+ type?: 'text' | 'email' | 'number' | 'tel' | 'url' | 'password';
2787
+ /** Подсказка внутри поля. */
2788
+ placeholder?: string;
2789
+ /** Блокирует ввод и редактирование. */
2790
+ disabled?: boolean;
2791
+ }
2792
+ ```
2793
+
2794
+ _Source: src/components/ui/input.tsx_
2795
+
2796
+ ### LoadingState
2797
+
2798
+ **Kind:** `function`
2799
+
2800
+ **Signature:**
2801
+ ```typescript
2802
+ export function LoadingState({
2803
+ title = 'Загрузка данных...',
2804
+ subtitle = 'Пожалуйста, подождите',
2805
+ className,
2806
+ }: LoadingStateProps): ReactNode
2807
+ ```
2808
+
2809
+ _Source: src/components/state/loading-state.tsx_
2810
+
2811
+ ### RadioGroup
2812
+
2813
+ **Kind:** `const`
2814
+
2815
+ Группа радио-кнопок из массива `options`. По умолчанию раскладывается
2816
+ вертикально; для горизонтальной — `className="!flex-row gap-6"`.
2817
+
2818
+ **Signature:**
2819
+ ```typescript
2820
+ const RadioGroup
2821
+ ```
2822
+
2823
+ **Examples:**
2824
+
2825
+ Вертикальная раскладка
2826
+ ```tsx
2827
+ import { RadioGroup } from '@reformer/ui-kit';
2828
+
2829
+ const LOAN_TYPES = [
2830
+ { value: 'consumer', label: 'Потребительский' },
2831
+ { value: 'mortgage', label: 'Ипотека' },
2832
+ { value: 'auto', label: 'Авто' },
2833
+ ];
2834
+
2835
+ <RadioGroup value={loanType} onChange={setLoanType} options={LOAN_TYPES} />
2836
+ ```
2837
+
2838
+ Горизонтальная раскладка размеров одежды
2839
+ ```tsx
2840
+ import { RadioGroup } from '@reformer/ui-kit';
2841
+
2842
+ <RadioGroup
2843
+ value={size}
2844
+ onChange={setSize}
2845
+ className="!flex-row gap-6"
2846
+ options={[
2847
+ { value: 's', label: 'S' },
2848
+ { value: 'm', label: 'M' },
2849
+ { value: 'l', label: 'L' },
2850
+ ]}
2851
+ />
2852
+ ```
2853
+
2854
+ _Source: src/components/ui/radio-group.tsx_
2855
+
2856
+ ### RadioGroupProps
2857
+
2858
+ **Kind:** `interface`
2859
+
2860
+ Props компонента {@link RadioGroup}.
2861
+
2862
+ **Signature:**
2863
+ ```typescript
2864
+ export interface RadioGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
2865
+ /** Дополнительный CSS-класс контейнера. По умолчанию вертикальная раскладка `flex flex-col gap-2`. */
2866
+ className?: string;
2867
+ /** Текущее значение. Должно совпадать с одним из `options[i].value`. `null` — ничего не выбрано. */
2868
+ value?: string | null;
2869
+ /** Обработчик выбора. Получает `event.target.value`. */
2870
+ onChange?: (value: string) => void;
2871
+ /** Срабатывает при потере фокуса любым из radio. */
2872
+ onBlur?: () => void;
2873
+ /** Список вариантов выбора. */
2874
+ options: RadioOption[];
2875
+ /** Блокирует все варианты. */
2876
+ disabled?: boolean;
2877
+ /** Test-id (используется как для контейнера, так и как префикс для каждого radio). */
2878
+ 'data-testid'?: string;
2879
+ }
2880
+ ```
2881
+
2882
+ _Source: src/components/ui/radio-group.tsx_
2883
+
2884
+ ### RadioOption
2885
+
2886
+ **Kind:** `interface`
2887
+
2888
+ Один вариант для {@link RadioGroup}.
2889
+
2890
+ **Signature:**
2891
+ ```typescript
2892
+ export interface RadioOption {
2893
+ /** Значение, попадающее в `onChange`. Должно быть строкой (DOM `value` всегда string). */
2894
+ value: string;
2895
+ /** Подпись варианта, отображаемая справа от radio. */
2896
+ label: string;
2897
+ }
2898
+ ```
2899
+
2900
+ _Source: src/components/ui/radio-group.tsx_
2901
+
2902
+ ### Section
2903
+
2904
+ **Kind:** `function`
2905
+
2906
+ Section - секция формы с заголовком.
2907
+
2908
+ Семантический `<section>`-контейнер для группировки связанных полей
2909
+ с опциональным заголовком (`titleAs` управляет уровнем `h1`-`h6`).
2910
+
2911
+ **Signature:**
2912
+ ```typescript
2913
+ export function Section({
2914
+ title,
2915
+ titleAs: TitleTag = 'h3',
2916
+ className,
2917
+ titleClassName,
2918
+ children,
2919
+ }: SectionProps): ReactNode
2920
+ ```
2921
+
2922
+ **Examples:**
2923
+
2924
+ Заголовок h2 + сетка из двух колонок
2925
+ ```typescript
2926
+ {
2927
+ component: Section,
2928
+ componentProps: {
2929
+ title: 'Личные данные',
2930
+ titleAs: 'h2',
2931
+ titleClassName: 'text-xl font-bold',
2932
+ className: 'grid grid-cols-2 gap-4',
2933
+ },
2934
+ children: [
2935
+ { component: path.firstName },
2936
+ { component: path.lastName },
2937
+ ],
2938
+ }
2939
+ ```
2940
+
2941
+ Section без заголовка (только обёртка)
2942
+ ```typescript
2943
+ {
2944
+ component: Section,
2945
+ componentProps: { className: 'space-y-4 mt-4' },
2946
+ children: [
2947
+ { component: path.address },
2948
+ { component: path.city },
2949
+ ],
2950
+ }
2951
+ ```
2952
+
2953
+ _Source: src/components/ui/section.tsx_
2954
+
2955
+ ### SectionProps
2956
+
2957
+ **Kind:** `interface`
2958
+
2959
+ Props компонента Section
2960
+
2961
+ **Signature:**
2962
+ ```typescript
2963
+ export interface SectionProps {
2964
+ /** Заголовок секции */
2965
+ title?: string;
2966
+ /** HTML элемент для заголовка (h1-h6). По умолчанию h3 */
2967
+ titleAs?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
2968
+ /** CSS класс для контейнера */
2969
+ className?: string;
2970
+ /** CSS класс для заголовка */
2971
+ titleClassName?: string;
2972
+ /** Дочерние элементы */
2973
+ children?: ReactNode;
2974
+ }
2975
+ ```
2976
+
2977
+ _Source: src/components/ui/section.tsx_
2978
+
2979
+ ### Select
2980
+
2981
+ **Kind:** `const`
2982
+
2983
+ Выпадающий список на `@radix-ui/react-select`. Поддерживает два режима
2984
+ источника данных: inline `options` и async `resource`. При `clearable=true`
2985
+ показывает крестик для сброса значения в `null`.
2986
+
2987
+ **Signature:**
2988
+ ```typescript
2989
+ const Select
2990
+ ```
2991
+
2992
+ **Examples:**
2993
+
2994
+ Inline-options
2995
+ ```tsx
2996
+ import { Select } from '@reformer/ui-kit';
2997
+
2998
+ <Select
2999
+ value={loanType}
3000
+ onChange={setLoanType}
3001
+ placeholder="Тип кредита"
3002
+ options={[
3003
+ { value: 'consumer', label: 'Потребительский' },
3004
+ { value: 'mortgage', label: 'Ипотека' },
3005
+ { value: 'auto', label: 'Авто' },
3006
+ ]}
3007
+ />
3008
+ ```
3009
+
3010
+ Grouped options
3011
+ ```tsx
3012
+ import { Select } from '@reformer/ui-kit';
3013
+
3014
+ <Select
3015
+ value={city}
3016
+ onChange={setCity}
3017
+ options={[
3018
+ { value: 'msk', label: 'Москва', group: 'Россия' },
3019
+ { value: 'spb', label: 'Санкт-Петербург', group: 'Россия' },
3020
+ { value: 'minsk', label: 'Минск', group: 'Беларусь' },
3021
+ ]}
3022
+ />
3023
+ ```
3024
+
3025
+ _Source: src/components/ui/select.tsx_
3026
+
3027
+ ### SelectContent
3028
+
3029
+ **Kind:** `function`
3030
+
3031
+ Дропдаун-контент {@link Select} (попап со списком). Обёртка над
3032
+ `Radix.Select.Content` с порталом, скролл-кнопками сверху/снизу и
3033
+ `position='popper'` по умолчанию (растягивается под ширину триггера).
3034
+
3035
+ **Signature:**
3036
+ ```typescript
3037
+ function SelectContent({
3038
+ className,
3039
+ children,
3040
+ position = 'popper',
3041
+ ...props
3042
+ }: React.ComponentProps<typeof SelectPrimitive.Content>)
3043
+ ```
3044
+
3045
+ **Examples:**
3046
+
3047
+ Popper-режим (default — ширина равна триггеру)
3048
+ ```tsx
3049
+ import { SelectContent, SelectItem } from '@reformer/ui-kit/select';
3050
+
3051
+ <SelectContent>
3052
+ <SelectItem value="a">A</SelectItem>
3053
+ <SelectItem value="b">B</SelectItem>
3054
+ </SelectContent>
3055
+ ```
3056
+
3057
+ Item-aligned (контент центрируется по выбранному элементу)
3058
+ ```tsx
3059
+ import { SelectContent, SelectItem } from '@reformer/ui-kit/select';
3060
+
3061
+ <SelectContent position="item-aligned">
3062
+ {LONG_LIST.map((x) => <SelectItem key={x.id} value={x.id}>{x.label}</SelectItem>)}
3063
+ </SelectContent>
3064
+ ```
3065
+
3066
+ _Source: src/components/ui/select.tsx_
3067
+
3068
+ ### SelectGroup
3069
+
3070
+ **Kind:** `function`
3071
+
3072
+ Группа `<SelectItem>` с заголовком {@link SelectLabel}. Тонкая обёртка над
3073
+ `Radix.Select.Group`. Используется при ручной сборке кастомного дропдауна
3074
+ вместо `options`-проп `Select`.
3075
+
3076
+ **Signature:**
3077
+ ```typescript
3078
+ function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>)
3079
+ ```
3080
+
3081
+ **Examples:**
3082
+
3083
+ Группа с заголовком
3084
+ ```tsx
3085
+ import { SelectGroup, SelectLabel, SelectItem } from '@reformer/ui-kit/select';
3086
+
3087
+ <SelectGroup>
3088
+ <SelectLabel>Фрукты</SelectLabel>
3089
+ <SelectItem value="apple">Яблоко</SelectItem>
3090
+ <SelectItem value="pear">Груша</SelectItem>
3091
+ </SelectGroup>
3092
+ ```
3093
+
3094
+ Несколько групп подряд внутри SelectContent
3095
+ ```tsx
3096
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@reformer/ui-kit/select';
3097
+
3098
+ <Select value={city} onChange={setCity}>
3099
+ <SelectTrigger><SelectValue placeholder="Город" /></SelectTrigger>
3100
+ <SelectContent>
3101
+ <SelectGroup>
3102
+ <SelectLabel>Россия</SelectLabel>
3103
+ <SelectItem value="msk">Москва</SelectItem>
3104
+ </SelectGroup>
3105
+ <SelectGroup>
3106
+ <SelectLabel>Беларусь</SelectLabel>
3107
+ <SelectItem value="minsk">Минск</SelectItem>
3108
+ </SelectGroup>
3109
+ </SelectContent>
3110
+ </Select>
3111
+ ```
3112
+
3113
+ _Source: src/components/ui/select.tsx_
3114
+
3115
+ ### SelectItem
3116
+
3117
+ **Kind:** `function`
3118
+
3119
+ Один пункт списка {@link Select}. Обёртка над `Radix.Select.Item` с
3120
+ чекмарком-индикатором (`CheckIcon`) для выбранного варианта.
3121
+
3122
+ **Signature:**
3123
+ ```typescript
3124
+ function SelectItem({
3125
+ className,
3126
+ children,
3127
+ ...props
3128
+ }: React.ComponentProps<typeof SelectPrimitive.Item>)
3129
+ ```
3130
+
3131
+ **Examples:**
3132
+
3133
+ Простой пункт
3134
+ ```tsx
3135
+ import { SelectItem } from '@reformer/ui-kit/select';
3136
+
3137
+ <SelectItem value="apple">Яблоко</SelectItem>
3138
+ ```
3139
+
3140
+ Заблокированный пункт
3141
+ ```tsx
3142
+ import { SelectItem } from '@reformer/ui-kit/select';
3143
+
3144
+ <SelectItem value="premium" disabled>
3145
+ Premium (требуется подписка)
3146
+ </SelectItem>
3147
+ ```
3148
+
3149
+ _Source: src/components/ui/select.tsx_
3150
+
3151
+ ### SelectLabel
3152
+
3153
+ **Kind:** `function`
3154
+
3155
+ Заголовок секции внутри {@link SelectGroup}. Обёртка над
3156
+ `Radix.Select.Label`. Не выбирается; используется для визуального разделения
3157
+ групп.
3158
+
3159
+ **Signature:**
3160
+ ```typescript
3161
+ function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>)
3162
+ ```
3163
+
3164
+ **Examples:**
3165
+
3166
+ Простая подпись группы
3167
+ ```tsx
3168
+ import { SelectGroup, SelectLabel, SelectItem } from '@reformer/ui-kit/select';
3169
+
3170
+ <SelectGroup>
3171
+ <SelectLabel>Овощи</SelectLabel>
3172
+ <SelectItem value="potato">Картошка</SelectItem>
3173
+ </SelectGroup>
3174
+ ```
3175
+
3176
+ Кастомный класс заголовка
3177
+ ```tsx
3178
+ import { SelectGroup, SelectLabel } from '@reformer/ui-kit/select';
3179
+
3180
+ <SelectGroup>
3181
+ <SelectLabel className="text-blue-600 font-bold">Премиум</SelectLabel>
3182
+ </SelectGroup>
3183
+ ```
3184
+
3185
+ _Source: src/components/ui/select.tsx_
3186
+
3187
+ ### SelectProps
3188
+
3189
+ **Kind:** `interface`
3190
+
3191
+ Props компонента {@link Select}.
3192
+
3193
+ **Signature:**
3194
+ ```typescript
3195
+ export interface SelectProps<T> extends Omit<
3196
+ React.ComponentProps<typeof SelectPrimitive.Root>,
3197
+ 'value' | 'onValueChange'
3198
+ > {
3199
+ /** Дополнительный CSS-класс для триггера. */
3200
+ className?: string;
3201
+ /** Выбранное значение. Всегда строка из `option.value`. `null` — ничего не выбрано. */
3202
+ value?: string | null;
3203
+ /** Обработчик выбора. При нажатии на крестик (`clearable`) приходит `null`. */
3204
+ onChange?: (value: string | null) => void;
3205
+ /** Срабатывает при закрытии дропдауна (через `onOpenChange(false)`). */
3206
+ onBlur?: () => void;
3207
+ /** Асинхронный источник опций. Если задан вместе с `options`, приоритет у `options`. */
3208
+ resource?: ResourceConfig<T>;
3209
+ /**
3210
+ * Inline-варианты. `value` приводится к строке. Опции с одинаковым `group`
3211
+ * объединяются в `SelectGroup` с `SelectLabel` (см. рецепт grouped options).
3212
+ */
3213
+ options?: Array<{ value: string | number; label: string; group?: string }>;
3214
+ /** Подсказка в триггере. По умолчанию `'Select an option...'`. */
3215
+ placeholder?: string;
3216
+ /** Блокирует выбор. */
3217
+ disabled?: boolean;
3218
+ /** Показывать ли кнопку очистки (X) справа от значения. По умолчанию `false`. */
3219
+ clearable?: boolean;
3220
+ }
3221
+ ```
3222
+
3223
+ _Source: src/components/ui/select.tsx_
3224
+
3225
+ ### SelectScrollDownButton
3226
+
3227
+ **Kind:** `function`
3228
+
3229
+ Кнопка-стрелка для скролла вниз в дропдауне {@link SelectContent}.
3230
+ `SelectContent` добавляет её автоматически, экспорт нужен для случаев
3231
+ полностью кастомной сборки.
3232
+
3233
+ **Signature:**
3234
+ ```typescript
3235
+ function SelectScrollDownButton({
3236
+ className,
3237
+ ...props
3238
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>)
3239
+ ```
3240
+
3241
+ **Examples:**
3242
+
3243
+ Автоматическое добавление SelectContent
3244
+ ```tsx
3245
+ import { SelectContent, SelectItem } from '@reformer/ui-kit/select';
3246
+
3247
+ <SelectContent>
3248
+ <SelectItem value="a">A</SelectItem>
3249
+ </SelectContent>
3250
+ ```
3251
+
3252
+ Ручное использование (кастомная сборка дропдауна)
3253
+ ```tsx
3254
+ import { SelectScrollDownButton } from '@reformer/ui-kit/select';
3255
+
3256
+ <SelectScrollDownButton className="bg-gray-50 text-gray-600" />
3257
+ ```
3258
+
3259
+ _Source: src/components/ui/select.tsx_
3260
+
3261
+ ### SelectScrollUpButton
3262
+
3263
+ **Kind:** `function`
3264
+
3265
+ Кнопка-стрелка для скролла вверх в дропдауне {@link SelectContent}.
3266
+ `SelectContent` добавляет её автоматически, экспорт нужен для случаев
3267
+ полностью кастомной сборки.
3268
+
3269
+ **Signature:**
3270
+ ```typescript
3271
+ function SelectScrollUpButton({
3272
+ className,
3273
+ ...props
3274
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>)
3275
+ ```
3276
+
3277
+ **Examples:**
3278
+
3279
+ Автоматическое добавление SelectContent
3280
+ ```tsx
3281
+ import { SelectContent, SelectItem } from '@reformer/ui-kit/select';
3282
+
3283
+ <SelectContent>
3284
+ <SelectItem value="a">A</SelectItem>
3285
+ </SelectContent>
3286
+ ```
3287
+
3288
+ Ручное использование (кастомная сборка дропдауна)
3289
+ ```tsx
3290
+ import { SelectScrollUpButton } from '@reformer/ui-kit/select';
3291
+
3292
+ <SelectScrollUpButton className="bg-gray-50 text-gray-600" />
3293
+ ```
3294
+
3295
+ _Source: src/components/ui/select.tsx_
3296
+
3297
+ ### SelectTrigger
3298
+
3299
+ **Kind:** `function`
3300
+
3301
+ Кнопка-открывалка списка для {@link Select}. Обёртка над
3302
+ `Radix.Select.Trigger` с дефолтными стилями (h-9, rounded, border) и
3303
+ `ChevronDown` справа.
3304
+
3305
+ **Signature:**
3306
+ ```typescript
3307
+ function SelectTrigger({
3308
+ className,
3309
+ size = 'default',
3310
+ children,
3311
+ 'aria-label': ariaLabel,
3312
+ ...props
3313
+ }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
3314
+ size?: 'sm' | 'default';
3315
+ })
3316
+ ```
3317
+
3318
+ **Examples:**
3319
+
3320
+ Дефолтный размер
3321
+ ```tsx
3322
+ import { SelectTrigger, SelectValue } from '@reformer/ui-kit/select';
3323
+
3324
+ <SelectTrigger>
3325
+ <SelectValue placeholder="Выберите страну" />
3326
+ </SelectTrigger>
3327
+ ```
3328
+
3329
+ Компактный (size='sm')
3330
+ ```tsx
3331
+ import { SelectTrigger, SelectValue } from '@reformer/ui-kit/select';
3332
+
3333
+ <SelectTrigger size="sm" className="w-32">
3334
+ <SelectValue placeholder="Сорт." />
3335
+ </SelectTrigger>
3336
+ ```
3337
+
3338
+ _Source: src/components/ui/select.tsx_
3339
+
3340
+ ### SelectValue
3341
+
3342
+ **Kind:** `function`
3343
+
3344
+ Отображает выбранное значение внутри {@link SelectTrigger}. Обёртка над
3345
+ `Radix.Select.Value`. Поддерживает `placeholder` для пустого состояния.
3346
+
3347
+ **Signature:**
3348
+ ```typescript
3349
+ function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>)
3350
+ ```
3351
+
3352
+ **Examples:**
3353
+
3354
+ Стандартный placeholder
3355
+ ```tsx
3356
+ import { SelectTrigger, SelectValue } from '@reformer/ui-kit/select';
3357
+
3358
+ <SelectTrigger>
3359
+ <SelectValue placeholder="Выберите вариант" />
3360
+ </SelectTrigger>
3361
+ ```
3362
+
3363
+ Кастомное представление выбранного значения через children
3364
+ ```tsx
3365
+ import { SelectTrigger, SelectValue } from '@reformer/ui-kit/select';
3366
+
3367
+ <SelectTrigger>
3368
+ <SelectValue placeholder="Не выбрано">
3369
+ {value && <span className="font-bold">{value}</span>}
3370
+ </SelectValue>
3371
+ </SelectTrigger>
3372
+ ```
3373
+
3374
+ _Source: src/components/ui/select.tsx_
3375
+
3376
+ ### StepIndicator
3377
+
3378
+ **Kind:** `const`
3379
+
3380
+ **Signature:**
3381
+ ```typescript
3382
+ export const StepIndicator: FC<StepIndicatorProps>
3383
+ ```
3384
+
3385
+ _Source: src/components/form-wizard/step-indicator.tsx_
3386
+
3387
+ ### Textarea
3388
+
3389
+ **Kind:** `const`
3390
+
3391
+ Многострочное поле ввода. Resize по вертикали разрешён (`resize-y`).
3392
+
3393
+ **Signature:**
3394
+ ```typescript
3395
+ const Textarea
3396
+ ```
3397
+
3398
+ **Examples:**
3399
+
3400
+ Поле для комментария с лимитом длины
3401
+ ```tsx
3402
+ import { Textarea } from '@reformer/ui-kit';
3403
+
3404
+ <Textarea
3405
+ value={comment}
3406
+ onChange={setComment}
3407
+ rows={5}
3408
+ maxLength={500}
3409
+ placeholder="Опишите проблему"
3410
+ />
3411
+ ```
3412
+
3413
+ Поле адреса
3414
+ ```tsx
3415
+ import { Textarea } from '@reformer/ui-kit';
3416
+
3417
+ <Textarea
3418
+ value={address}
3419
+ onChange={setAddress}
3420
+ rows={3}
3421
+ placeholder="Адрес доставки"
3422
+ />
3423
+ ```
3424
+
3425
+ _Source: src/components/ui/textarea.tsx_
3426
+
3427
+ ### TextareaProps
3428
+
3429
+ **Kind:** `interface`
3430
+
3431
+ Props компонента {@link Textarea}.
3432
+
3433
+ **Signature:**
3434
+ ```typescript
3435
+ export interface TextareaProps extends Omit<
3436
+ React.TextareaHTMLAttributes<HTMLTextAreaElement>,
3437
+ 'value' | 'onChange'
3438
+ > {
3439
+ /** Дополнительный CSS-класс. */
3440
+ className?: string;
3441
+ /** Текущее значение. `null`/`undefined` рендерится как пустое поле. */
3442
+ value?: string | null;
3443
+ /** Обработчик изменений. Пустая строка приводится к `null`. */
3444
+ onChange?: (value: string | null) => void;
3445
+ /** Срабатывает при потере фокуса. */
3446
+ onBlur?: () => void;
3447
+ /** Подсказка внутри поля. */
3448
+ placeholder?: string;
3449
+ /** Блокирует ввод. */
3450
+ disabled?: boolean;
3451
+ /** Видимая высота в строках. По умолчанию `3`. */
3452
+ rows?: number;
3453
+ /**
3454
+ * Hard-лимит длины (нативное HTML-поведение). Используется как soft-protection
3455
+ * на уровне UI; для бизнес-валидации добавляй `maxLength` через `validations`.
3456
+ */
3457
+ maxLength?: number;
3458
+ }
3459
+ ```
3460
+
3461
+ _Source: src/components/ui/textarea.tsx_