@reformer/ui-kit 1.0.0-beta.1

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