@reformer/core 10.2.0 → 11.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/behaviors.js +134 -134
  2. package/dist/form/form-submitter.d.ts +0 -4
  3. package/dist/form/hooks/types.d.ts +3 -3
  4. package/dist/form/index.d.ts +0 -3
  5. package/dist/form/nodes/array-node.d.ts +0 -84
  6. package/dist/form/nodes/form-node.d.ts +0 -14
  7. package/dist/form/nodes/group-node.d.ts +0 -4
  8. package/dist/form/status-machine.d.ts +0 -38
  9. package/dist/form/types/contracts.d.ts +0 -6
  10. package/dist/form/types/deep-schema.d.ts +0 -9
  11. package/dist/form/types/index.d.ts +4 -38
  12. package/dist/form/types/schema-node.d.ts +13 -16
  13. package/dist/form/types/validation-schema.d.ts +0 -25
  14. package/dist/form/unique-id.d.ts +3 -11
  15. package/dist/form/validators.d.ts +8 -3
  16. package/dist/form-model-DhW2HZOB.js +323 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +448 -636
  19. package/dist/runtime-token.d.ts +23 -0
  20. package/dist/state/form-model.d.ts +14 -0
  21. package/dist/state/index.d.ts +2 -1
  22. package/dist/state/types.d.ts +39 -11
  23. package/dist/state.js +18 -18
  24. package/dist/validation.js +1 -1
  25. package/dist/validators.d.ts +0 -4
  26. package/dist/validators.js +88 -122
  27. package/docs/llms/01-api-reference.md +63 -0
  28. package/docs/llms/02-quick-start.md +143 -0
  29. package/docs/llms/03-api-signatures.md +273 -0
  30. package/docs/llms/04-common-patterns.md +202 -0
  31. package/docs/llms/05-common-mistakes.md +167 -0
  32. package/docs/llms/06-troubleshooting.md +11 -0
  33. package/docs/llms/07-complete-import.md +99 -0
  34. package/docs/llms/08-form-types.md +47 -0
  35. package/docs/llms/09-formschema.md +143 -0
  36. package/docs/llms/10-arrays.md +161 -0
  37. package/docs/llms/11-async-watchfield.md +53 -0
  38. package/docs/llms/12-array-cleanup.md +31 -0
  39. package/docs/llms/13-multi-step.md +91 -0
  40. package/docs/llms/14-extended-mistakes.md +98 -0
  41. package/docs/llms/15-project-structure.md +125 -0
  42. package/docs/llms/16-ui-components.md +189 -0
  43. package/docs/llms/17-nonexistent-api.md +93 -0
  44. package/docs/llms/18-conditional-fields.md +183 -0
  45. package/docs/llms/19-reading-values.md +88 -0
  46. package/docs/llms/20-compute-vs-watch.md +147 -0
  47. package/docs/llms/21-array-operations.md +113 -0
  48. package/docs/llms/22-cycle-detection.md +86 -0
  49. package/docs/llms/23-copy-from.md +141 -0
  50. package/docs/llms/24-sync-fields.md +114 -0
  51. package/docs/llms/25-reset-when.md +115 -0
  52. package/docs/llms/26-transform-value.md +136 -0
  53. package/docs/llms/27-revalidate-when.md +132 -0
  54. package/docs/llms/28-submit-and-reset.md +259 -0
  55. package/docs/llms/29-async-preload.md +193 -0
  56. package/docs/llms/30-type-safety-recipes.md +159 -0
  57. package/docs/llms/31-async-validator-debounce.md +135 -0
  58. package/docs/llms/32-async-options-loading.md +108 -0
  59. package/docs/llms/33-validation-strategy.md +193 -0
  60. package/llms.txt +219 -1533
  61. package/package.json +33 -10
  62. package/dist/derived-registry-CluZCQ7U.js +0 -16
  63. package/dist/form-model-DYCzGGKF.js +0 -286
@@ -0,0 +1,53 @@
1
+ ## 10. ASYNC REACTION — onChange (CRITICALLY IMPORTANT)
2
+
3
+ Для async-реакции на изменение поля (загрузка зависимых опций, справочников) используй
4
+ `onChange` из `@reformer/core/behaviors`. Колбэк выполняется ВНЕ effect-контекста (можно
5
+ безопасно писать сигналы/ноды), а 2-й аргумент — `{ signal }` (AbortSignal): при следующей
6
+ смене значения предыдущий вызов аннулируется — передавай `signal` в `fetch`.
7
+
8
+ ```typescript
9
+ import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
10
+
11
+ const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
12
+ // CORRECT — async onChange со всеми safeguards
13
+ onChange(
14
+ model.$.parentField,
15
+ async (value, { signal }) => {
16
+ if (!value) {
17
+ form.dependentField.updateComponentProps({ options: [] });
18
+ return;
19
+ }
20
+ try {
21
+ const data = await fetchData(value, { signal }); // отмена устаревших запросов
22
+ form.dependentField.updateComponentProps({ options: data });
23
+ } catch (error) {
24
+ if ((error as Error).name === 'AbortError') return;
25
+ form.dependentField.updateComponentProps({ options: [] });
26
+ }
27
+ },
28
+ { debounce: 300 } // не fetch на каждое нажатие
29
+ );
30
+ });
31
+ ```
32
+
33
+ ### Опции onChange
34
+
35
+ - `debounce: 300` — не дёргать сеть на каждый keystroke (300–500 мс рекомендуется).
36
+ - `immediate: true` — вызвать колбэк сразу при регистрации (по умолчанию `false`).
37
+ - Guard-clause — пропустить пустое значение.
38
+ - try/catch + проверка `AbortError` — обработка отмены и ошибок.
39
+
40
+ ### Низкоуровневый примитив watchField
41
+
42
+ `watchField` из `@reformer/core` — базовая подписка на изменение сигнала (без debounce и
43
+ AbortSignal). `onChange` построен поверх него. Для простых синхронных реакций:
44
+
45
+ ```typescript
46
+ import { watchField } from '@reformer/core';
47
+
48
+ // вызывается при каждом изменении (по умолчанию НЕ на инициализации)
49
+ const stop = watchField(model.$.country, (country) => {
50
+ model.city = ''; // сброс зависимого поля
51
+ });
52
+ // stop() — отписаться
53
+ ```
@@ -0,0 +1,31 @@
1
+ ## 11. ARRAY CLEANUP PATTERN
2
+
3
+ Очистка массива при выключении флага — через `onChange` на сигнале флага + `clear()` на
4
+ модели-массиве. Колбит выполняется вне effect-контекста, поэтому мутировать массив безопасно.
5
+
6
+ ```typescript
7
+ import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
8
+
9
+ const behavior = defineFormBehavior<MyForm>(({ model }) => {
10
+ // при снятии флага — очистить массив
11
+ onChange(model.$.hasItems, (hasItems) => {
12
+ if (!hasItems) model.items.clear();
13
+ });
14
+ });
15
+ ```
16
+
17
+ Переиспользуемый оператор (как в монорепо):
18
+
19
+ ```typescript
20
+ import { onChange } from '@reformer/core/behaviors';
21
+ import type { ReadonlySignal } from '@reformer/core/behaviors';
22
+
23
+ function clearWhenOff(flag: ReadonlySignal<boolean>, array: { clear(): void }): void {
24
+ onChange(flag, (on) => {
25
+ if (!on) array.clear();
26
+ });
27
+ }
28
+
29
+ // в схеме поведения:
30
+ clearWhenOff(model.$.hasProperty, model.properties);
31
+ ```
@@ -0,0 +1,91 @@
1
+ ## 12. MULTI-STEP FORM VALIDATION
2
+
3
+ Каждый шаг — своя `ValidationSchema<Form>` (обычная функция `({ model }) => void`, обёрнутая
4
+ `defineValidationSchema`). Переход к следующему шагу проверяется `validateModel(model, stepSchema)`;
5
+ полный submit — по общей схеме (композиция шагов через `apply(...)`). `validateModel` сам разносит
6
+ ошибки по нодам формы (`getNodeForSignal(sig).setErrors(...)`), поэтому UI подсветит проблемные поля
7
+ текущего шага автоматически. Валидация живёт ОТДЕЛЬНО от layout: сам `RenderNode`/JSON-узел валидаторов
8
+ не несёт — схема инъектируется в рантайме и роутит ошибки по тем же нодам.
9
+
10
+ ```typescript
11
+ import { type FormModel } from '@reformer/core';
12
+ import {
13
+ validate,
14
+ apply,
15
+ defineValidationSchema,
16
+ validateModel,
17
+ type ValidationSchema,
18
+ } from '@reformer/core/validation';
19
+ import { required, min } from '@reformer/core/validators';
20
+
21
+ // Под-схема шага — обычная функция ({ model }) => void. Значения проверяет оператор validate(sig, [rules]).
22
+ const step1Schema = defineValidationSchema<Form>(({ model }) => {
23
+ validate(model.$.loanType, [required()]);
24
+ validate(model.$.loanAmount, [required(), min(50000)]);
25
+ });
26
+
27
+ const step2Schema = defineValidationSchema<Form>(({ model }) => {
28
+ validate(model.$.personalData.firstName, [required()]);
29
+ validate(model.$.personalData.lastName, [required()]);
30
+ });
31
+
32
+ // Карта шагов + полная схема (композиция под-схем через apply — заменяет пошаговую группировку деревом)
33
+ const STEP_SCHEMAS: readonly ValidationSchema<Form>[] = [step1Schema, step2Schema];
34
+ const fullSchema = defineValidationSchema<Form>(() => apply(...STEP_SCHEMAS));
35
+ ```
36
+
37
+ ```typescript
38
+ // Переход к следующему шагу. validateModel возвращает Promise<boolean> (true = нет блокирующих ошибок;
39
+ // severity:'warning' не блокирует). Устаревшие прогоны той же (model, schema) отменяются автоматически.
40
+ const goToNextStep = async () => {
41
+ const ok = await validateModel(model, STEP_SCHEMAS[currentStep - 1]);
42
+ if (!ok) return; // ошибки уже проставлены в ноды текущего шага
43
+ setCurrentStep(currentStep + 1);
44
+ };
45
+
46
+ // Полный submit — по общей схеме
47
+ const handleSubmit = async () => {
48
+ const ok = await validateModel(model, fullSchema);
49
+ if (ok) {
50
+ await onSubmit(model.get());
51
+ }
52
+ };
53
+ ```
54
+
55
+ Слой-потребитель (`FormWizard`) обычно оборачивает это в конфиг с per-step и полной валидацией:
56
+
57
+ ```typescript
58
+ function makeValidationConfig(model: FormModel<Form>) {
59
+ return {
60
+ validateStep: (n: number): Promise<boolean> => validateModel(model, STEP_SCHEMAS[n - 1]),
61
+ validateAll: (): Promise<boolean> => validateModel(model, fullSchema),
62
+ };
63
+ }
64
+ ```
65
+
66
+ ### Multi-Step Component Example
67
+
68
+ ```tsx
69
+ function MultiStepForm() {
70
+ const [step, setStep] = useState(1);
71
+
72
+ const nextStep = async () => {
73
+ const ok = await validateModel(model, STEP_SCHEMAS[step - 1]);
74
+ if (ok) setStep(step + 1);
75
+ };
76
+
77
+ return (
78
+ <div>
79
+ {step === 1 && <Step1Fields form={form} />}
80
+ {step === 2 && <Step2Fields form={form} />}
81
+
82
+ <button onClick={() => setStep(step - 1)} disabled={step === 1}>
83
+ Back
84
+ </button>
85
+ <button onClick={step === 2 ? handleSubmit : nextStep}>
86
+ {step === 2 ? 'Submit' : 'Next'}
87
+ </button>
88
+ </div>
89
+ );
90
+ }
91
+ ```
@@ -0,0 +1,98 @@
1
+ ## 13. EXTENDED COMMON MISTAKES
2
+
3
+ ### Reuse via apply / applyEach — не дублируй код
4
+
5
+ ```typescript
6
+ import { defineFormBehavior, apply, applyEach, compute } from '@reformer/core/behaviors';
7
+
8
+ // ✅ apply — одна под-схема на несколько групп
9
+ const addressBehavior = defineFormBehavior<Address>(({ model }) => {
10
+ compute(model.$.full, () => `${model.city}, ${model.street}`);
11
+ });
12
+
13
+ const behavior = defineFormBehavior<Form>(({ model }) => {
14
+ apply([model.$.homeAddress, model.$.workAddress], addressBehavior);
15
+
16
+ // ✅ applyEach — под-схема на КАЖДЫЙ элемент массива (реагирует на add/remove)
17
+ applyEach(
18
+ model.$.items,
19
+ defineFormBehavior<Item>(({ model: row }) => {
20
+ compute(row.$.lineTotal, () => row.qty * row.price);
21
+ })
22
+ );
23
+ });
24
+ ```
25
+
26
+ ### Идемпотентность transformValue
27
+
28
+ ```typescript
29
+ // ❌ неидемпотентный transformer — бесконечный цикл setValue → callback → setValue
30
+ transformValue(model.$.field, (v) => `prefix-${v}`); // f(f(x)) ≠ f(x)
31
+
32
+ // ✅ guard «уже преобразовано»
33
+ transformValue(model.$.field, (v) => (v?.startsWith('prefix-') ? v : `prefix-${v}`));
34
+ ```
35
+
36
+ ### compute для производных полей вместо ручной синхронизации
37
+
38
+ ```typescript
39
+ // ❌ ручной onChange + запись — легко зациклить/забыть кейс
40
+ onChange(model.$.firstName, () => { model.fullName = `${model.firstName} ${model.lastName}`; });
41
+
42
+ // ✅ compute: цель не входит в источники → цикла нет, запись идемпотентна (peek-guard)
43
+ compute(model.$.fullName, () => [model.firstName, model.lastName].filter(Boolean).join(' '));
44
+ ```
45
+
46
+ ### Расходящийся цикл compute (Cycle detected)
47
+
48
+ Взаимные `compute`/`computeFrom` без стабилизации preact обрывает как «Cycle detected».
49
+ DSL перехватывает это и бросает понятную ошибку с именем поля. Решение — разорвать цикл
50
+ условием `when` или читать одну сторону через `peek()`:
51
+
52
+ ```typescript
53
+ // ❌ взаимный пересчёт без стабилизации
54
+ compute(model.$.a, () => model.b + 1);
55
+ compute(model.$.b, () => model.a + 1); // расходится → Cycle detected
56
+
57
+ // ✅ добавь стабилизирующее условие или однонаправленную зависимость
58
+ compute(model.$.total, () => model.price * model.qty); // одно направление
59
+ ```
60
+
61
+ ### Cross-field валидация — через `cross(sig, fn)`
62
+
63
+ Cross-field живёт в схеме валидации (`@reformer/core/validation`), а не в layout: правило вешается на
64
+ ПОЛЕ-НОСИТЕЛЬ ошибки оператором `cross(sig, fn)`, а `fn` читает снапшот текущего scope (`model.get()`).
65
+
66
+ ```typescript
67
+ import { defineValidationSchema, validate, cross, validateModel } from '@reformer/core/validation';
68
+ import { required } from '@reformer/core/validators';
69
+ import { revalidateWhen, type ValidationError } from '@reformer/core';
70
+
71
+ // ❌ старое (УДАЛЕНО): дерево { value, validators }, ModelValidator (value, scope, root), validateFormModel
72
+ const legacyRule: ModelValidator<number, unknown, Form> = (_value, _scope, root) =>
73
+ root.field1 > root.field2 ? { code: 'error', message: 'Invalid' } : null;
74
+ const legacySchema = { field1: { value: model.$.field1, component: Input, validators: [legacyRule] } };
75
+ validateFormModel(model, legacySchema);
76
+
77
+ // ✅ новое: cross-правило — обычная функция над снапшотом (model.get()), не читает scope/root
78
+ const field1LessThanField2 = (f: Form): ValidationError | null =>
79
+ f.field1 > f.field2 ? { code: 'error', message: 'Invalid' } : null;
80
+
81
+ const schema = defineValidationSchema<Form>(({ model }) => {
82
+ validate(model.$.field1, [required()]);
83
+ cross(model.$.field1, field1LessThanField2); // правило на поле-носителе ошибки
84
+ });
85
+
86
+ // Прогон — ТОЛЬКО внешним раннером; ошибки сами доезжают до нод формы.
87
+ // ⚠️ form.validate()/submit() схему валидации больше НЕ прогоняют.
88
+ await validateModel(model, schema);
89
+
90
+ // Перезапуск правила при изменении соседнего поля — мост «поведение → валидация»:
91
+ revalidateWhen([model.$.field2], () => void validateModel(model, schema));
92
+ ```
93
+
94
+ > **Актуальный vs удалённый API.** Операторы валидации `validate`/`validateAsync`/`validateWhen`/`cross`/`each`/`apply`
95
+ > ТЕПЕРЬ существуют — в `@reformer/core/validation` с новыми сигнатурами (ambient, активны только внутри прогона
96
+ > `validateModel`). УДАЛЕНЫ: `applyWhen`, `validateGroup`/`validateTree`/`validateForm`/`validateFormModel`,
97
+ > типы `FieldPath`/`ValidationSchemaFn`/`BehaviorSchemaFn` и `ctx.form.*`/`ctx.setFieldValue` (наследие
98
+ > path-based архитектуры). См. `17-nonexistent-api.md`.
@@ -0,0 +1,125 @@
1
+ ## 14. PROJECT STRUCTURE (COLOCATION)
2
+
3
+ One form is one module folder. **Default layout — flat minimalist:** every concern is a single
4
+ file at the module root, and the whole form (entry + all wizard steps inline) lives in one
5
+ `index.tsx`. Filenames are flat with no prefix — **the dot-prefix (`form.` / `renderer.`) is
6
+ carried only by `schema` and `behavior`**, the two concerns that have both a model-layer and a
7
+ render-layer version (`form.schema.ts` / `form.behavior.ts` here; renderer slices add
8
+ `renderer.schema.*` / `renderer.behavior.ts`). No `lib/` / `schema/` / `components/steps/`
9
+ nesting until the form grows (see "Scaling up" below). Arrays are declared in `form.schema.ts`
10
+ and rendered with `FormArraySection` — no per-step component files.
11
+
12
+ ```
13
+ src/
14
+ ├── components/ui/ # App-wide reusable UI (FormField, FormArraySection, ...)
15
+ │
16
+ ├── forms/
17
+ │ └── [form-name]/ # Form module — flat, one file per concern
18
+ │ ├── index.tsx # entry + whole form: createModel→createForm→<FormWizard> with all steps inline; arrays via FormArraySection
19
+ │ ├── types.ts # form type + enums + { value, label } option type + constant dictionaries
20
+ │ ├── model.ts # createModel + initial values + empty-array-element factories
21
+ │ ├── form.schema.ts # FormSchema: { value: model.$.x, component, componentProps }
22
+ │ ├── form.behavior.ts # defineFormBehavior: compute / enableWhen / hideWhen / copyFrom / onChange
23
+ │ ├── validation.ts # ALL validation → { validateStep, validateAll }
24
+ │ ├── data-sources.ts # options + async loaders (dataSources)
25
+ │ └── api.ts # submit + prefill/load
26
+ ```
27
+
28
+ Rule of thumb: **one concern → one file at the module root; the whole component tree (all steps) →
29
+ `index.tsx`; validation is a single `validation.ts`.** This is the working default for
30
+ almost every form — reach for folders only when a file stops fitting on a screen.
31
+
32
+ ### Key Files
33
+
34
+ ```typescript
35
+ // forms/credit-application/types.ts
36
+ export type CreditApplicationForm = {
37
+ loanType: LoanType;
38
+ loanAmount: number | null;
39
+ // ...
40
+ };
41
+
42
+ // forms/credit-application/model.ts
43
+ import { createModel, type FormModel } from '@reformer/core';
44
+ export const createCreditApplicationModel = (): FormModel<CreditApplicationForm> =>
45
+ createModel<CreditApplicationForm>(createInitialCreditApplication());
46
+
47
+ // forms/credit-application/form.schema.ts
48
+ import type { FormModel } from '@reformer/core';
49
+ export const creditApplicationSchema = (model: FormModel<CreditApplicationForm>) => ({
50
+ loanType: { value: model.$.loanType, component: Select, componentProps: { /* ... */ } },
51
+ personalData: personalDataNodes(model.$.personalData),
52
+ properties: { array: model.properties, item: propertyItem },
53
+ });
54
+
55
+ // forms/credit-application/form.behavior.ts
56
+ import { defineFormBehavior, compute, enableWhen } from '@reformer/core/behaviors';
57
+ export const creditApplicationBehavior = defineFormBehavior<CreditApplicationForm>(({ model }) => {
58
+ compute(model.$.monthlyPayment, () => computeMonthlyPayment(model));
59
+ enableWhen([model.$.propertyValue], () => model.loanType === 'mortgage', { resetOnDisable: true });
60
+ });
61
+
62
+ // forms/credit-application/index.tsx — entry: assembles the form, renders <FormWizard> with all steps inline
63
+ import { createForm } from '@reformer/core';
64
+ export const createCreditApplicationForm = () => {
65
+ const model = createCreditApplicationModel();
66
+ return createForm({ model, schema: creditApplicationSchema(model), behavior: creditApplicationBehavior });
67
+ };
68
+ ```
69
+
70
+ ### Scaling up: folders (large forms)
71
+
72
+ When the flat module gets unwieldy — steps you want in their own files, sub-forms reused across
73
+ steps, calc/validators that outgrow one file — promote it to a three-folder module: `lib/`
74
+ (domain raw material), `schema/` (the form definition), `components/` (React layout), plus the
75
+ entry component and `index.ts`. Keep the module root to just the entry + `index.ts`; everything
76
+ else lives in a folder.
77
+
78
+ ```
79
+ forms/
80
+ └── [form-name]/ # Form module — folders (scale-up)
81
+ ├── [FormName]Form.tsx # Entry: builds model+form, renders FormWizard + step components
82
+ ├── index.ts # Public re-exports of the module
83
+ │
84
+ ├── lib/ # Domain helpers (target-agnostic)
85
+ │ ├── types.ts # Form interface + field enums + { value, label } option type
86
+ │ ├── constants.ts # Option dictionaries (LOAN_TYPES, GENDERS, ...)
87
+ │ ├── calc.ts # Pure fns for derived fields (age, monthlyPayment, ...)
88
+ │ ├── custom-validators.ts # Reusable validator factories
89
+ │ └── api.ts # Data sources + submit
90
+ │
91
+ ├── schema/ # The form definition
92
+ │ ├── model.ts # createModel factory + initial values + array-element factories
93
+ │ ├── schema.ts # createForm schema tree ({ value: model.$.x, component })
94
+ │ ├── validation.ts # defineValidationSchema + validateModel config ({ validateStep, validateAll })
95
+ │ ├── behavior.ts # defineFormBehavior(...)
96
+ │ └── create-form.ts # Assembly: createForm({ model, schema, behavior })
97
+ │
98
+ └── components/
99
+ ├── steps/ # One component per wizard step
100
+ ├── nested-forms/ # Reusable sub-forms (Address, PersonalData, ...)
101
+ └── ui/ # Form-specific helper blocks (summary, warnings, sections)
102
+ ```
103
+
104
+ Rule of thumb for this layout: **domain raw material → `lib/`; anything describing the form →
105
+ `schema/`; React layout → `components/`; root = entry + `index.ts`.** In very large forms you
106
+ may co-locate each step's `schema.ts` / `validation.ts` / `behavior.ts` inside its
107
+ `steps/[Step]/` folder, keeping only the shared `model` and cross-step rules in `schema/` — see
108
+ the guide below.
109
+
110
+ ### Scaling
111
+
112
+ | Complexity | Structure |
113
+ | ---------- | --------------------------------------------------------------------------------------- |
114
+ | Simple | Single file: `index.tsx` (model + schema + behavior + component) |
115
+ | **Minimalist (flat)** | **Default.** One file per concern at the module root (`types` / `model` / `form.schema` / `form.behavior` / `validation` / `data-sources` / `api`) + `index.tsx` with all steps inline |
116
+ | Folders (`lib/` + `schema/` + `components/`) | Large forms: split concerns into folders, one component per step, reusable `nested-forms/` |
117
+
118
+ > The leading layout is configurable: set `REFORMER_FORM_LAYOUT` (`minimalist` | `folders`)
119
+ > when registering the MCP server to choose which structure the generators lead with. Default
120
+ > is `minimalist`.
121
+
122
+ > Cross-target variants (renderer-react `renderer.schema.ts` + `renderer.behavior.ts` /
123
+ > renderer-json `renderer.schema.json` + `renderer.behavior.ts` + `registry.ts`), the
124
+ > centralized-vs-co-located choice, and the full reuse map live in the
125
+ > **form-directory-layout** guide (`@reformer/mcp`).
@@ -0,0 +1,189 @@
1
+ ## 14.5 UI COMPONENT PATTERNS
2
+
3
+ > **Default rule (read first)**: для UI используй `FormField` из
4
+ > [`@reformer/ui-kit`](../../reformer-ui-kit/) — он покрывает 95% случаев одной
5
+ > строкой `<FormField control={form.x} />`. Свои field-обёртки пиши ТОЛЬКО если
6
+ > ui-kit не подходит (другая design system, особый low-level input).
7
+ >
8
+ > Канонический schema-driven подход:
9
+ >
10
+ > - **компонент** объявляется в схеме как `component: Input` (или `Select`, `Checkbox`, etc.)
11
+ > - **пропсы** компонента — в `componentProps: { label, placeholder, options, type, ... }`
12
+ > - **JSX рендерит**: `<FormField control={form.x} />` БЕЗ дополнительных props
13
+ >
14
+ > См. `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`
15
+ > для полного руководства.
16
+
17
+ ### Default — FormField из ui-kit (canonical)
18
+
19
+ ```tsx
20
+ import { useMemo } from 'react';
21
+ import { createModel, createForm } from '@reformer/core';
22
+ import { FormField, Input, Select, Checkbox, Button } from '@reformer/ui-kit';
23
+
24
+ type RegistrationForm = {
25
+ email: string;
26
+ country: string;
27
+ agree: boolean;
28
+ };
29
+
30
+ function RegistrationPage() {
31
+ const form = useMemo(() => {
32
+ const model = createModel<RegistrationForm>({ email: '', country: 'ru', agree: false });
33
+ const schema = {
34
+ email: {
35
+ value: model.$.email,
36
+ component: Input,
37
+ componentProps: { label: 'Email', type: 'email', placeholder: 'you@example.com' },
38
+ },
39
+ country: {
40
+ value: model.$.country,
41
+ component: Select,
42
+ componentProps: {
43
+ label: 'Country',
44
+ options: [
45
+ { value: 'ru', label: 'Россия' },
46
+ { value: 'by', label: 'Беларусь' },
47
+ ],
48
+ },
49
+ },
50
+ agree: {
51
+ value: model.$.agree,
52
+ component: Checkbox,
53
+ componentProps: { label: 'I agree to terms' },
54
+ },
55
+ };
56
+ return createForm<RegistrationForm>({ model, schema });
57
+ }, []);
58
+
59
+ return (
60
+ <form>
61
+ <FormField control={form.email} testId="email" />
62
+ <FormField control={form.country} testId="country" />
63
+ <FormField control={form.agree} testId="agree" />
64
+ <Button type="submit">Register</Button>
65
+ </form>
66
+ );
67
+ }
68
+ ```
69
+
70
+ `FormField` сам читает `componentProps.label`, `componentProps.placeholder`,
71
+ `componentProps.options` через `useFormControl(...).componentProps` и применяет
72
+ их к нужному `<input>`/`<select>`/etc. Error rendering, `pending` для async-валидаций,
73
+ `data-testid` для e2e — всё из коробки.
74
+
75
+ ### Anti-patterns (не делай так)
76
+
77
+ ❌ **Свои field-компоненты с label-prop'ами в JSX**:
78
+
79
+ ```tsx
80
+ // WRONG — дублирует логику FormField, ломает schema-driven архитектуру
81
+ <Input control={form.email} label="Email" placeholder="..." />
82
+ <Select control={form.country} options={[...]} />
83
+ ```
84
+
85
+ ❌ **Передача компонент-пропсов через JSX вместо схемы**:
86
+
87
+ ```tsx
88
+ // WRONG — нарушает single source of truth (схема)
89
+ <FormField control={form.email} label="Email" />
90
+ ```
91
+
92
+ ✅ Всё это в схеме:
93
+
94
+ ```ts
95
+ { email: { component: Input, componentProps: { label: 'Email' } } }
96
+ ```
97
+
98
+ ```tsx
99
+ <FormField control={form.email} />
100
+ ```
101
+
102
+ ### Advanced — кастомный input через `children` slot
103
+
104
+ Когда нужен низкоуровневый input, которого нет в ui-kit (маска, особый combobox):
105
+
106
+ ```tsx
107
+ import { FormField } from '@reformer/ui-kit';
108
+ import { InputMask } from 'react-input-mask';
109
+
110
+ <FormField control={form.phone} testId="phone">
111
+ <InputMask mask="+7 (999) 999-99-99" />
112
+ </FormField>;
113
+ ```
114
+
115
+ `children` оборачивается в `CdkFormField.Control asChild` и получает все нужные
116
+ props (`value`, `onChange`, `onBlur`, `aria-invalid`).
117
+
118
+ ### Advanced — write your own from scratch (rare)
119
+
120
+ Если ты не хочешь подключать `@reformer/ui-kit`, пиши свои компоненты на основе
121
+ `useFormControl` — но **сохраняй schema-driven подход**: читай label/placeholder
122
+ из `componentProps`, не из JSX-props.
123
+
124
+ ```tsx
125
+ import type { FieldNode } from '@reformer/core';
126
+ import { useFormControl } from '@reformer/core';
127
+
128
+ type MyFormFieldProps<T> = { control: FieldNode<T> }; // ← ОДИН prop
129
+
130
+ function MyFormField<T>({ control }: MyFormFieldProps<T>) {
131
+ const { value, errors, disabled, shouldShowError, componentProps } = useFormControl(control);
132
+ // componentProps = { label, placeholder, type, options, ... } — из СХЕМЫ
133
+ const cp = (componentProps ?? {}) as Record<string, unknown>;
134
+
135
+ return (
136
+ <label>
137
+ {cp.label && <span>{cp.label as string}</span>}
138
+ <input
139
+ type={(cp.type as string) ?? 'text'}
140
+ value={(value ?? '') as string}
141
+ placeholder={cp.placeholder as string | undefined}
142
+ disabled={disabled}
143
+ onChange={(e) => (control.setValue as (v: unknown) => void)(e.target.value)}
144
+ onBlur={() => control.markAsTouched()}
145
+ />
146
+ {shouldShowError && errors[0] && <span>{errors[0].message}</span>}
147
+ </label>
148
+ );
149
+ }
150
+ ```
151
+
152
+ Использование — как у `FormField`:
153
+
154
+ ```tsx
155
+ <MyFormField control={form.email} /> // ← без label-prop
156
+ ```
157
+
158
+ ### Integration with UI libraries (shadcn etc.)
159
+
160
+ > **Через рендерер — без обёртки.** Если ты рендеришь форму через `@reformer/renderer-react` /
161
+ > `@reformer/renderer-json`, вместо ручной обёртки на каждый контрол можно зарегистрировать сырой
162
+ > компонент и передать `settings.resolveFieldAdapter` — рендерер сам сведёт value-seam к диалекту
163
+ > контрола. Ручная обёртка на `useFormControl` (ниже) нужна для прямого JSX без рендерера.
164
+
165
+ Если есть существующая design system — оборачивай её компоненты в один
166
+ `MyFormField` (как выше) и используй один прop `control`. Не множь обёртки на
167
+ тип input'а — пусть `componentProps.type` диспатчит внутри.
168
+
169
+ ```tsx
170
+ import { Input } from '@/components/ui/input';
171
+ import { Label } from '@/components/ui/label';
172
+
173
+ function ShadcnFormField({ control }: { control: FieldNode<string> }) {
174
+ const { value, errors, disabled, componentProps } = useFormControl(control);
175
+ const cp = (componentProps ?? {}) as Record<string, unknown>;
176
+
177
+ return (
178
+ <div className="space-y-2">
179
+ {cp.label && <Label>{cp.label as string}</Label>}
180
+ <Input
181
+ value={(value ?? '') as string}
182
+ onChange={(e) => control.setValue(e.target.value)}
183
+ disabled={disabled}
184
+ />
185
+ {errors[0] && <p className="text-red-500">{errors[0].message}</p>}
186
+ </div>
187
+ );
188
+ }
189
+ ```