@reformer/core 2.0.0-beta.12 → 2.0.0-beta.13

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.
@@ -34,4 +34,4 @@ import { FieldPathNode } from '../../types';
34
34
  * }
35
35
  * ```
36
36
  */
37
- export declare function email<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions): void;
37
+ export declare function email<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions): void;
@@ -37,4 +37,4 @@ import { FieldPathNode } from '../../types';
37
37
  * }
38
38
  * ```
39
39
  */
40
- export declare function pattern<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, regex: RegExp, options?: ValidateOptions): void;
40
+ export declare function pattern<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, regex: RegExp, options?: ValidateOptions): void;
@@ -21,7 +21,7 @@ export type PhoneFormat = 'international' | 'ru' | 'us' | 'any';
21
21
  * phone(path.phoneNumber, { format: 'international', message: 'Неверный формат телефона' });
22
22
  * ```
23
23
  */
24
- export declare function phone<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
24
+ export declare function phone<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
25
25
  /** Формат телефона */
26
26
  format?: PhoneFormat;
27
27
  }): void;
@@ -14,7 +14,7 @@ import { FieldPathNode } from '../../types';
14
14
  * url(path.website, { requireProtocol: true });
15
15
  * ```
16
16
  */
17
- export declare function url<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
17
+ export declare function url<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
18
18
  /** Требовать наличие протокола (http:// или https://) */
19
19
  requireProtocol?: boolean;
20
20
  /** Разрешенные протоколы */
package/llms.txt CHANGED
@@ -34,6 +34,8 @@
34
34
  - 28-submit-and-reset.md — Submit и Reset — Жизненный цикл отправки формы
35
35
  - 29-async-preload.md — Async Preload — Загрузка начальных значений и динамических справочников
36
36
  - 30-type-safety-recipes.md — type-safety-recipes
37
+ - 31-async-validator-debounce.md — async-validator-debounce
38
+ - 32-async-options-loading.md — async-options-loading
37
39
  - API Reference (auto-generated from JSDoc)
38
40
 
39
41
  ## 1. 1. API Reference
@@ -81,23 +83,38 @@ const { value, errors, disabled } = useFormControl(control.loanType);
81
83
 
82
84
  ## 2. 1.5 QUICK START - Minimal Working Form
83
85
 
86
+ > **Schema-driven UI rule (read first)**: компонент И его пропсы (label, placeholder,
87
+ > options, type) объявляются в **схеме поля** (`component` + `componentProps`).
88
+ > В JSX рендерится один универсальный `<FormField control={form.x} />` из
89
+ > `@reformer/ui-kit` БЕЗ дополнительных props. Не пиши свои `Input`/`Select`/
90
+ > `Checkbox`-обёртки с `label`-prop'ами — это anti-pattern. См.
91
+ > `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`.
92
+
84
93
  ```typescript
85
- import { createForm, useFormControl } from '@reformer/core';
94
+ import { createForm, type FormProxy, type FormSchema } from '@reformer/core';
86
95
  import { required, email } from '@reformer/core/validators';
87
- import type { FormProxy } from '@reformer/core';
96
+ import { FormField, Input, Button } from '@reformer/ui-kit';
88
97
 
89
- // 1. Define form type
90
- interface ContactForm {
98
+ // 1. Define form type as `type` alias (not `interface` — see Recipe 2)
99
+ type ContactForm = {
91
100
  name: string;
92
101
  email: string;
93
- }
102
+ };
94
103
 
95
- // 2. Create form schema with validation
104
+ // 2. Schema: component + componentProps decl in fields, no JSX label props
96
105
  const form = createForm<ContactForm>({
97
106
  form: {
98
- name: { value: '', component: Input },
99
- email: { value: '', component: Input },
100
- },
107
+ name: {
108
+ value: '',
109
+ component: Input,
110
+ componentProps: { label: 'Name', placeholder: 'Your name' },
111
+ },
112
+ email: {
113
+ value: '',
114
+ component: Input,
115
+ componentProps: { label: 'Email', type: 'email' },
116
+ },
117
+ } satisfies FormSchema<ContactForm>,
101
118
  validation: (path) => {
102
119
  required(path.name, { message: 'Name is required' });
103
120
  required(path.email, { message: 'Email is required' });
@@ -105,52 +122,68 @@ const form = createForm<ContactForm>({
105
122
  },
106
123
  });
107
124
 
108
- // 3. Use in React component
125
+ // 3. Use in React component — thin JSX, FormField does ALL heavy lifting
109
126
  function ContactFormComponent() {
110
- const nameCtrl = useFormControl(form.name);
111
- const emailCtrl = useFormControl(form.email);
112
-
113
127
  const handleSubmit = async () => {
114
- await form.submit((values) => {
128
+ await form.submit((values: ContactForm) => {
115
129
  console.log('Form submitted:', values);
116
130
  });
117
131
  };
118
132
 
119
133
  return (
120
134
  <form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
121
- <div>
122
- <input
123
- value={nameCtrl.value}
124
- onChange={(e) => form.name.setValue(e.target.value)}
125
- disabled={nameCtrl.disabled}
126
- />
127
- {nameCtrl.errors.map((err) => <span key={err.code}>{err.message}</span>)}
128
- </div>
129
- <div>
130
- <input
131
- value={emailCtrl.value}
132
- onChange={(e) => form.email.setValue(e.target.value)}
133
- disabled={emailCtrl.disabled}
134
- />
135
- {emailCtrl.errors.map((err) => <span key={err.code}>{err.message}</span>)}
136
- </div>
137
- <button type="submit">Send</button>
135
+ <FormField control={form.name} testId="name" />
136
+ <FormField control={form.email} testId="email" />
137
+ <Button type="submit">Send</Button>
138
138
  </form>
139
139
  );
140
140
  }
141
141
 
142
142
  // 4. Pass form to child components via props (NOT context!)
143
- interface FormStepProps {
143
+ type FormStepProps = {
144
144
  form: FormProxy<ContactForm>;
145
- }
145
+ };
146
146
 
147
147
  function FormStep({ form }: FormStepProps) {
148
- // Access form fields directly
149
- const { value } = useFormControl(form.name);
150
- return <div>Name: {value}</div>;
148
+ return <FormField control={form.name} testId="name" />;
151
149
  }
152
150
  ```
153
151
 
152
+ ### Arrays of objects — tuple format
153
+
154
+ ⚠️ Массивы объектов в схеме объявляются **через tuple `[itemSchema]`**, НЕ через `FieldConfig` с `value: []`:
155
+
156
+ ```typescript
157
+ // ❌ DON'T — TS error: Type 'FieldConfig<PropertyItem[]>' is not assignable to type '[FormSchema<PropertyItem>]'
158
+ const schema: FormSchema<{ properties: PropertyItem[] }> = {
159
+ properties: { value: [], component: Input }, // ← intuitive but WRONG
160
+ };
161
+
162
+ // ✅ DO — tuple [itemSchema]: ArrayNode infers item shape from первого элемента
163
+ const schema: FormSchema<{ properties: PropertyItem[] }> = {
164
+ properties: [
165
+ {
166
+ type: { value: 'apartment', component: Select, componentProps: { ... } } satisfies FieldConfig<PropertyType>,
167
+ description: { value: '', component: Textarea, componentProps: { label: 'Описание' } },
168
+ estimatedValue: { value: 0, component: Input, componentProps: { label: 'Стоимость', type: 'number' } },
169
+ },
170
+ ],
171
+ };
172
+ ```
173
+
174
+ В runtime массив пуст (`form.properties.value === []`); tuple — это **template** для каждого item, который применяется при `form.properties.push()` / `.insert()`. См. подробности в `find_recipe(topic="form-array")` и `find_recipe(topic="array-operations")`.
175
+
176
+ ### When to write your own field components (advanced — rare)
177
+
178
+ Свои компоненты нужны ТОЛЬКО если:
179
+
180
+ - ты намеренно избегаешь `@reformer/ui-kit` (например, проект уже имеет свою design system)
181
+ - нужен особый низкоуровневый input, который не покрывается `FormField` + `componentProps`
182
+
183
+ В этом случае см. секцию `## 14.5 UI COMPONENT PATTERNS` ниже — но даже там
184
+ паттерн **schema-driven** (label/options не из JSX-props, а из `componentProps` через
185
+ `useFormControl(...).componentProps`).
186
+
154
187
  ## 3. 2. API SIGNATURES
155
188
 
156
189
  ### Validators
@@ -501,6 +534,40 @@ Reference patterns: `complex-multy-step-form/schemas/credit-application-behavior
501
534
 
502
535
  ## 6. 4. COMMON MISTAKES
503
536
 
537
+ ### TSC overload-resolution error: `'form' does not exist in FormSchema<T>`
538
+
539
+ **Симптом**:
540
+ ```
541
+ TS2769: Object literal may only specify known properties, and 'form' does not
542
+ exist in type 'FormSchema<MyForm>'.
543
+ ```
544
+
545
+ (или похожее с `validation` / `behavior` ключами).
546
+
547
+ **Причина**: `createForm` имеет 2 overload — `GroupNodeConfig<T>` (с `form/validation/behavior`)
548
+ и schema-only. Если в inline literal `form: { ... }` есть глубокая inference-проблема
549
+ (чаще всего — literal default value у union-типа поля, см. Recipe 8 в
550
+ [30-type-safety-recipes.md](30-type-safety-recipes.md)), TS отбрасывает Overload 1 и
551
+ матчит Overload 2 (schema-only). В нём ключа `form` нет — получаешь misleading error.
552
+
553
+ **Workaround** — extract `form` literal в typed local:
554
+
555
+ ```ts
556
+ const form: FormSchema<MyForm> = {
557
+ fieldA: { value: 'foo', component: Input, /* ... */ },
558
+ // ...
559
+ };
560
+
561
+ createForm({ form, validation, behavior });
562
+ ```
563
+
564
+ Теперь TS репортит **реальную** per-field ошибку — например:
565
+ ```
566
+ TS2322: Type '"male"' is not assignable to type 'Gender | null'
567
+ ```
568
+
569
+ После этого решай конкретную проблему (Recipe 8 — `satisfies FieldConfig<UnionType>`).
570
+
504
571
  ### Imports rule (#1 cause of cascading errors — read first)
505
572
 
506
573
  Types live in `@reformer/core`. Functions live in submodules (`/validators`,
@@ -1286,105 +1353,180 @@ export const creditApplicationValidation: ValidationSchemaFn<Form> = (path) => {
1286
1353
 
1287
1354
  ## 17. 14.5 UI COMPONENT PATTERNS
1288
1355
 
1289
- ReFormer does NOT provide UI components - you create them yourself or use a UI library.
1356
+ > **Default rule (read first)**: для UI используй `FormField` из
1357
+ > [`@reformer/ui-kit`](../../reformer-ui-kit/) — он покрывает 95% случаев одной
1358
+ > строкой `<FormField control={form.x} />`. Свои field-обёртки пиши ТОЛЬКО если
1359
+ > ui-kit не подходит (другая design system, особый low-level input).
1360
+ >
1361
+ > Канонический schema-driven подход:
1362
+ > - **компонент** объявляется в схеме как `component: Input` (или `Select`, `Checkbox`, etc.)
1363
+ > - **пропсы** компонента — в `componentProps: { label, placeholder, options, type, ... }`
1364
+ > - **JSX рендерит**: `<FormField control={form.x} />` БЕЗ дополнительных props
1365
+ >
1366
+ > См. `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`
1367
+ > для полного руководства.
1290
1368
 
1291
- ### Generic FormField Component
1369
+ ### Default FormField из ui-kit (canonical)
1292
1370
 
1293
1371
  ```tsx
1294
- import type { FieldNode } from '@reformer/core';
1295
- import { useFormControl } from '@reformer/core';
1372
+ import { useMemo } from 'react';
1373
+ import { createForm, type FormSchema } from '@reformer/core';
1374
+ import { FormField, Input, Select, Checkbox, Button } from '@reformer/ui-kit';
1296
1375
 
1297
- interface FormFieldProps<T> {
1298
- control: FieldNode<T>;
1299
- label?: string;
1300
- type?: 'text' | 'email' | 'number' | 'password';
1301
- placeholder?: string;
1302
- }
1376
+ type RegistrationForm = {
1377
+ email: string;
1378
+ country: string;
1379
+ agree: boolean;
1380
+ };
1303
1381
 
1304
- function FormField<T extends string | number>({
1305
- control,
1306
- label,
1307
- type = 'text',
1308
- placeholder
1309
- }: FormFieldProps<T>) {
1310
- const { value, errors, disabled, touched } = useFormControl(control);
1311
- const showError = touched && errors.length > 0;
1382
+ function RegistrationPage() {
1383
+ const form = useMemo(() =>
1384
+ createForm<RegistrationForm>({
1385
+ form: {
1386
+ email: {
1387
+ value: '',
1388
+ component: Input,
1389
+ componentProps: { label: 'Email', type: 'email', placeholder: 'you@example.com' },
1390
+ },
1391
+ country: {
1392
+ value: 'ru',
1393
+ component: Select,
1394
+ componentProps: {
1395
+ label: 'Country',
1396
+ options: [
1397
+ { value: 'ru', label: 'Россия' },
1398
+ { value: 'by', label: 'Беларусь' },
1399
+ ],
1400
+ },
1401
+ },
1402
+ agree: {
1403
+ value: false,
1404
+ component: Checkbox,
1405
+ componentProps: { label: 'I agree to terms' },
1406
+ },
1407
+ } satisfies FormSchema<RegistrationForm>,
1408
+ }),
1409
+ []
1410
+ );
1312
1411
 
1313
1412
  return (
1314
- <div className="form-field">
1315
- {label && <label>{label}</label>}
1316
- <input
1317
- type={type}
1318
- value={value ?? ''}
1319
- onChange={(e) => {
1320
- const val = type === 'number'
1321
- ? Number(e.target.value) as T
1322
- : e.target.value as T;
1323
- control.setValue(val);
1324
- }}
1325
- onBlur={() => control.markAsTouched()}
1326
- disabled={disabled}
1327
- placeholder={placeholder}
1328
- className={showError ? 'error' : ''}
1329
- />
1330
- {showError && (
1331
- <span className="error-message">{errors[0].message}</span>
1332
- )}
1333
- </div>
1413
+ <form>
1414
+ <FormField control={form.email} testId="email" />
1415
+ <FormField control={form.country} testId="country" />
1416
+ <FormField control={form.agree} testId="agree" />
1417
+ <Button type="submit">Register</Button>
1418
+ </form>
1334
1419
  );
1335
1420
  }
1421
+ ```
1422
+
1423
+ `FormField` сам читает `componentProps.label`, `componentProps.placeholder`,
1424
+ `componentProps.options` через `useFormControl(...).componentProps` и применяет
1425
+ их к нужному `<input>`/`<select>`/etc. Error rendering, `pending` для async-валидаций,
1426
+ `data-testid` для e2e — всё из коробки.
1427
+
1428
+ ### Anti-patterns (не делай так)
1336
1429
 
1337
- // Usage
1338
- <FormField control={form.email} label="Email" type="email" />
1339
- <FormField control={form.age} label="Age" type="number" />
1430
+ **Свои field-компоненты с label-prop'ами в JSX**:
1431
+ ```tsx
1432
+ // WRONG — дублирует логику FormField, ломает schema-driven архитектуру
1433
+ <Input control={form.email} label="Email" placeholder="..." />
1434
+ <Select control={form.country} options={[...]} />
1340
1435
  ```
1341
1436
 
1342
- ### FormField for Select
1437
+ **Передача компонент-пропсов через JSX вместо схемы**:
1438
+ ```tsx
1439
+ // WRONG — нарушает single source of truth (схема)
1440
+ <FormField control={form.email} label="Email" />
1441
+ ```
1442
+
1443
+ ✅ Всё это в схеме:
1444
+ ```ts
1445
+ { email: { component: Input, componentProps: { label: 'Email' } } }
1446
+ ```
1343
1447
 
1344
1448
  ```tsx
1345
- interface SelectFieldProps<T extends string> {
1346
- control: FieldNode<T>;
1347
- label?: string;
1348
- options: Array<{ value: T; label: string }>;
1349
- }
1449
+ <FormField control={form.email} />
1450
+ ```
1451
+
1452
+ ### Advanced кастомный input через `children` slot
1453
+
1454
+ Когда нужен низкоуровневый input, которого нет в ui-kit (маска, особый combobox):
1455
+
1456
+ ```tsx
1457
+ import { FormField } from '@reformer/ui-kit';
1458
+ import { InputMask } from 'react-input-mask';
1459
+
1460
+ <FormField control={form.phone} testId="phone">
1461
+ <InputMask mask="+7 (999) 999-99-99" />
1462
+ </FormField>
1463
+ ```
1350
1464
 
1351
- function SelectField<T extends string>({ control, label, options }: SelectFieldProps<T>) {
1352
- const { value, errors, disabled, touched } = useFormControl(control);
1465
+ `children` оборачивается в `CdkFormField.Control asChild` и получает все нужные
1466
+ props (`value`, `onChange`, `onBlur`, `aria-invalid`).
1467
+
1468
+ ### Advanced — write your own from scratch (rare)
1469
+
1470
+ Если ты не хочешь подключать `@reformer/ui-kit`, пиши свои компоненты на основе
1471
+ `useFormControl` — но **сохраняй schema-driven подход**: читай label/placeholder
1472
+ из `componentProps`, не из JSX-props.
1473
+
1474
+ ```tsx
1475
+ import type { FieldNode } from '@reformer/core';
1476
+ import { useFormControl } from '@reformer/core';
1477
+
1478
+ type MyFormFieldProps<T> = { control: FieldNode<T> }; // ← ОДИН prop
1479
+
1480
+ function MyFormField<T>({ control }: MyFormFieldProps<T>) {
1481
+ const { value, errors, disabled, shouldShowError, componentProps } = useFormControl(control);
1482
+ // componentProps = { label, placeholder, type, options, ... } — из СХЕМЫ
1483
+ const cp = (componentProps ?? {}) as Record<string, unknown>;
1353
1484
 
1354
1485
  return (
1355
- <div className="form-field">
1356
- {label && <label>{label}</label>}
1357
- <select
1358
- value={value}
1359
- onChange={(e) => control.setValue(e.target.value as T)}
1486
+ <label>
1487
+ {cp.label && <span>{cp.label as string}</span>}
1488
+ <input
1489
+ type={(cp.type as string) ?? 'text'}
1490
+ value={(value ?? '') as string}
1491
+ placeholder={cp.placeholder as string | undefined}
1360
1492
  disabled={disabled}
1361
- >
1362
- {options.map((opt) => (
1363
- <option key={opt.value} value={opt.value}>
1364
- {opt.label}
1365
- </option>
1366
- ))}
1367
- </select>
1368
- {touched && errors[0] && <span className="error-message">{errors[0].message}</span>}
1369
- </div>
1493
+ onChange={(e) => (control.setValue as (v: unknown) => void)(e.target.value)}
1494
+ onBlur={() => control.markAsTouched()}
1495
+ />
1496
+ {shouldShowError && errors[0] && <span>{errors[0].message}</span>}
1497
+ </label>
1370
1498
  );
1371
1499
  }
1372
1500
  ```
1373
1501
 
1374
- ### Integration with UI Libraries
1502
+ Использование как у `FormField`:
1503
+
1504
+ ```tsx
1505
+ <MyFormField control={form.email} /> // ← без label-prop
1506
+ ```
1507
+
1508
+ ### Integration with UI libraries (shadcn etc.)
1509
+
1510
+ Если есть существующая design system — оборачивай её компоненты в один
1511
+ `MyFormField` (как выше) и используй один прop `control`. Не множь обёртки на
1512
+ тип input'а — пусть `componentProps.type` диспатчит внутри.
1375
1513
 
1376
1514
  ```tsx
1377
- // With shadcn/ui
1378
1515
  import { Input } from '@/components/ui/input';
1379
1516
  import { Label } from '@/components/ui/label';
1380
1517
 
1381
- function ShadcnFormField({ control, label }: FormFieldProps<string>) {
1382
- const { value, errors, disabled } = useFormControl(control);
1518
+ function ShadcnFormField({ control }: { control: FieldNode<string> }) {
1519
+ const { value, errors, disabled, componentProps } = useFormControl(control);
1520
+ const cp = (componentProps ?? {}) as Record<string, unknown>;
1383
1521
 
1384
1522
  return (
1385
1523
  <div className="space-y-2">
1386
- <Label>{label}</Label>
1387
- <Input value={value} onChange={(e) => control.setValue(e.target.value)} disabled={disabled} />
1524
+ {cp.label && <Label>{cp.label as string}</Label>}
1525
+ <Input
1526
+ value={(value ?? '') as string}
1527
+ onChange={(e) => control.setValue(e.target.value)}
1528
+ disabled={disabled}
1529
+ />
1388
1530
  {errors[0] && <p className="text-red-500">{errors[0].message}</p>}
1389
1531
  </div>
1390
1532
  );
@@ -3550,6 +3692,44 @@ const navRef = useRef<FormWizardHandle<MyForm>>(null);
3550
3692
  />
3551
3693
  ```
3552
3694
 
3695
+ ### Recipe 8 — enum-typed default values (union literal widening)
3696
+
3697
+ Когда default `value` поля — литерал union-type, TS widens его до `string`,
3698
+ ломая `FormSchema<T>` assignability:
3699
+
3700
+ ```typescript
3701
+ type Gender = 'male' | 'female';
3702
+
3703
+ // ❌ TS error: '"male"' is not assignable to 'Gender | null'
3704
+ const schema: FormSchema<{ gender: Gender }> = {
3705
+ gender: {
3706
+ value: 'male', // widens to string
3707
+ component: RadioGroup,
3708
+ componentProps: { options: [...] },
3709
+ },
3710
+ };
3711
+ ```
3712
+
3713
+ **Fix** — `satisfies FieldConfig<UnionType>` сужает без `as`-каста:
3714
+
3715
+ ```typescript
3716
+ gender: {
3717
+ value: 'male',
3718
+ component: RadioGroup,
3719
+ componentProps: { options: [...] },
3720
+ } satisfies FieldConfig<Gender>,
3721
+ ```
3722
+
3723
+ Альтернативы:
3724
+
3725
+ - `value: 'male' as Gender` — works, но `as`-каст. Избегай (см. anti-patterns).
3726
+ - `value: null` (если `Gender | null` допустим) — нет widening проблемы, но требует
3727
+ null-check в render/validation.
3728
+
3729
+ Применяется к любому union-полю: `loanType`, `employmentStatus`, `maritalStatus`,
3730
+ `propertyType`, и т.п. Симптом без этого fix'а — misleading TS2769
3731
+ "`'form' does not exist in FormSchema<...>`" (см. `05-common-mistakes.md`).
3732
+
3553
3733
  ### Anti-patterns to avoid
3554
3734
 
3555
3735
  - `import { type ValidationSchemaFn } from '@reformer/core/validators'` →
@@ -3562,7 +3742,167 @@ const navRef = useRef<FormWizardHandle<MyForm>>(null);
3562
3742
  - `as never` cast on `computeFrom` target — never necessary; if you reach
3563
3743
  for it, you have one of the issues above.
3564
3744
 
3565
- ## 67. API Reference
3745
+ ## 67. 31. ASYNC VALIDATOR WITH DEBOUNCE
3746
+
3747
+ Для проверок типа «уникальность email», «валидация INN через API», «проверка
3748
+ адреса по DaData» используй `asyncValidators` в `FieldConfig`:
3749
+
3750
+ ```ts
3751
+ import { type FieldConfig, type FormSchema, type AsyncValidatorFn } from '@reformer/core';
3752
+ import { required, email } from '@reformer/core/validators';
3753
+
3754
+ const checkEmailUnique: AsyncValidatorFn<string> = async (value) => {
3755
+ if (!value) return null; // empty = valid (sync `required` separately)
3756
+
3757
+ try {
3758
+ const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`);
3759
+ const { available } = (await res.json()) as { available: boolean };
3760
+ return available
3761
+ ? null
3762
+ : { code: 'email-taken', message: 'Email уже зарегистрирован' };
3763
+ } catch {
3764
+ return { code: 'check-failed', message: 'Не удалось проверить email' };
3765
+ }
3766
+ };
3767
+
3768
+ const schema: FormSchema<{ email: string }> = {
3769
+ email: {
3770
+ value: '',
3771
+ component: Input,
3772
+ validators: [required(), email()], // sync first
3773
+ asyncValidators: [checkEmailUnique], // async after sync passed
3774
+ debounce: 500, // debounce input → API (поле `debounce`, не `asyncDebounceMs`)
3775
+ },
3776
+ };
3777
+ ```
3778
+
3779
+ ### Lifecycle
3780
+
3781
+ 1. На каждое `setValue` запускается sync `validators` (`required`, `email`)
3782
+ 2. Если sync passed — стартует таймер `debounce`
3783
+ 3. По истечении debounce и стабильном value — вызывается каждый `asyncValidator`
3784
+ 4. Во время async-проверки `useFormControl(...).pending === true` — UI может показать спиннер
3785
+ 5. Результат записывается в `errors` поля
3786
+
3787
+ ### UI integration
3788
+
3789
+ `FormField` из `@reformer/ui-kit` автоматически рендерит `<span>Проверка...</span>`
3790
+ когда `pending === true`. Если рендеришь сам — используй:
3791
+
3792
+ ```tsx
3793
+ const { pending, errors } = useFormControl(form.email);
3794
+ return pending ? <Spinner /> : errors.length ? <Error errors={errors} /> : null;
3795
+ ```
3796
+
3797
+ ### Common patterns
3798
+
3799
+ - **Debounce 500ms** — баланс между UX и API rate-limit. Для дорогих API увеличивай
3800
+ до 1000-2000ms.
3801
+ - **Cancellation** — если `setValue` приходит во время выполнения предыдущего async,
3802
+ ReFormer автоматически отбрасывает результат старого вызова. Не нужно вручную
3803
+ abort'ить fetch (но reasonable practice — поддержать `AbortSignal` через
3804
+ `ctx.signal` если есть).
3805
+ - **Cross-field async** — для валидаций типа «дата начала > дата окончания» используй
3806
+ sync `validators` с `applyWhen`, а не `asyncValidators`.
3807
+
3808
+ ### See also
3809
+
3810
+ - [11-async-watchfield.md](11-async-watchfield.md) — async для `watchField`, не валидаторов
3811
+ - [29-async-preload.md](29-async-preload.md) — async preload данных в init формы
3812
+ - API: `validateAsync(field): Promise<boolean>` — manual trigger, обычно не нужен
3813
+
3814
+ ## 68. 32. ASYNC OPTIONS LOADING
3815
+
3816
+ Для динамической подгрузки опций dropdown'а в зависимости от значения другого поля
3817
+ (например: `region` → `city options`, `carBrand` → `carModel options`) — используй
3818
+ `watchField` + `updateComponentProps({ options })`:
3819
+
3820
+ ```ts
3821
+ import { watchField } from '@reformer/core/behaviors';
3822
+ import type { BehaviorSchemaFn } from '@reformer/core';
3823
+
3824
+ type CityOption = { value: string; label: string };
3825
+
3826
+ async function fetchCitiesByRegion(region: string): Promise<CityOption[]> {
3827
+ const res = await fetch(`/api/cities?region=${encodeURIComponent(region)}`);
3828
+ return res.json();
3829
+ }
3830
+
3831
+ const behavior: BehaviorSchemaFn<MyForm> = (path) => {
3832
+ watchField(
3833
+ path.registrationAddress.region,
3834
+ async (region, ctx) => {
3835
+ if (!region) {
3836
+ ctx.form.registrationAddress.city.updateComponentProps({ options: [] });
3837
+ ctx.form.registrationAddress.city.setValue('');
3838
+ return;
3839
+ }
3840
+
3841
+ // Set loading state (optional — UI показывает spinner)
3842
+ ctx.form.registrationAddress.city.updateComponentProps({
3843
+ loading: true,
3844
+ options: [],
3845
+ });
3846
+
3847
+ try {
3848
+ const options = await fetchCitiesByRegion(region);
3849
+ ctx.form.registrationAddress.city.updateComponentProps({
3850
+ loading: false,
3851
+ options,
3852
+ });
3853
+ } catch {
3854
+ ctx.form.registrationAddress.city.updateComponentProps({
3855
+ loading: false,
3856
+ options: [],
3857
+ });
3858
+ // optionally — set error на поле через setError
3859
+ }
3860
+ },
3861
+ { debounce: 300 }
3862
+ );
3863
+ };
3864
+ ```
3865
+
3866
+ ### Lifecycle
3867
+
3868
+ 1. `watchField` подписан на изменения `path.region`
3869
+ 2. На каждое изменение — debounce 300ms (чтобы не fetch на каждое нажатие клавиши)
3870
+ 3. После debounce — async `fetchCitiesByRegion(region)`
3871
+ 4. Результат — `updateComponentProps({ options })` на target поле
3872
+ 5. UI (Select / Combobox) автоматически обновится через signal на componentProps
3873
+
3874
+ ### Common patterns
3875
+
3876
+ - **Debounce 300-500ms** — баланс между UX и rate-limit
3877
+ - **Reset target value** — при смене source очисти `setValue('')` чтобы не остался stale выбор
3878
+ - **Loading state** — `componentProps.loading: true` пока fetch идёт (UI компонент должен это поддержать)
3879
+ - **Cancellation** — ReFormer **не** отменяет старый fetch автоматически. Если предыдущий fetch ещё идёт, а пришло новое значение — новый запустится параллельно. Может приводить к race. Workaround: использовать `AbortController` через closure:
3880
+ ```ts
3881
+ let abortController: AbortController | null = null;
3882
+ watchField(path.region, async (region, ctx) => {
3883
+ abortController?.abort();
3884
+ abortController = new AbortController();
3885
+ try {
3886
+ const opts = await fetchCities(region, { signal: abortController.signal });
3887
+ ctx.form.city.updateComponentProps({ options: opts });
3888
+ } catch (e) {
3889
+ if ((e as Error).name === 'AbortError') return;
3890
+ throw e;
3891
+ }
3892
+ });
3893
+ ```
3894
+
3895
+ ### Initial load (preload at form mount)
3896
+
3897
+ Для preload опций при инициализации формы — используй `async-preload` recipe (см. [29-async-preload.md](29-async-preload.md)). Не `watchField` — он не triggers на init.
3898
+
3899
+ ### See also
3900
+
3901
+ - [11-async-watchfield.md](11-async-watchfield.md) — общий паттерн async в `watchField`
3902
+ - [29-async-preload.md](29-async-preload.md) — preload данных при инициализации
3903
+ - [31-async-validator-debounce.md](31-async-validator-debounce.md) — для async **валидации** (не options)
3904
+
3905
+ ## 69. API Reference
3566
3906
 
3567
3907
  _Auto-generated from JSDoc on public exports._
3568
3908
 
@@ -4640,7 +4980,7 @@ _Source: src/core/behavior/behaviors/enable-when.ts_
4640
4980
 
4641
4981
  **Signature:**
4642
4982
  ```typescript
4643
- export function email<TForm, TField extends string | undefined = string>(
4983
+ export function email<TForm, TField extends string | null | undefined = string>(
4644
4984
  fieldPath: FieldPathNode<TForm, TField> | undefined,
4645
4985
  options?: ValidateOptions
4646
4986
  ): void
@@ -6947,7 +7287,7 @@ _Source: src/core/utils/field-path-navigator.ts_
6947
7287
 
6948
7288
  **Signature:**
6949
7289
  ```typescript
6950
- export function pattern<TForm, TField extends string | undefined = string>(
7290
+ export function pattern<TForm, TField extends string | null | undefined = string>(
6951
7291
  fieldPath: FieldPathNode<TForm, TField> | undefined,
6952
7292
  regex: RegExp,
6953
7293
  options?: ValidateOptions
@@ -6994,7 +7334,7 @@ _Source: src/core/validation/validators/pattern.ts_
6994
7334
 
6995
7335
  **Signature:**
6996
7336
  ```typescript
6997
- export function phone<TForm, TField extends string | undefined = string>(
7337
+ export function phone<TForm, TField extends string | null | undefined = string>(
6998
7338
  fieldPath: FieldPathNode<TForm, TField> | undefined,
6999
7339
  options?: ValidateOptions & {
7000
7340
  /** Формат телефона */
@@ -7953,7 +8293,7 @@ _Source: src/core/types/index.ts_
7953
8293
 
7954
8294
  **Signature:**
7955
8295
  ```typescript
7956
- export function url<TForm, TField extends string | undefined = string>(
8296
+ export function url<TForm, TField extends string | null | undefined = string>(
7957
8297
  fieldPath: FieldPathNode<TForm, TField> | undefined,
7958
8298
  options?: ValidateOptions & {
7959
8299
  /** Требовать наличие протокола (http:// или https://) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reformer/core",
3
- "version": "2.0.0-beta.12",
3
+ "version": "2.0.0-beta.13",
4
4
  "description": "Reactive form state management library for React with signals-based architecture",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",