@xbeeant/form-engine-react 0.0.3 → 0.0.4

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 (142) hide show
  1. package/dist/cjs/components/FormController.cjs +1 -1
  2. package/dist/cjs/components/NexusField.cjs +1 -1
  3. package/dist/cjs/components/NexusForm.cjs +1 -1
  4. package/dist/cjs/components/form-controller.cjs +1 -0
  5. package/dist/cjs/components/nexus-field.cjs +1 -0
  6. package/dist/cjs/components/nexus-form-provider.cjs +1 -0
  7. package/dist/cjs/components/nexus-form.cjs +1 -0
  8. package/dist/cjs/components/nexus-layout.cjs +1 -0
  9. package/dist/cjs/components/nexus-object.cjs +1 -0
  10. package/dist/cjs/contexts/field-inherit-context.cjs +1 -0
  11. package/dist/cjs/contexts/grid-context.cjs +1 -0
  12. package/dist/cjs/contexts/layout-config-context.cjs +1 -0
  13. package/dist/cjs/contexts/nexus-context.cjs +1 -0
  14. package/dist/cjs/hooks/index.cjs +1 -1
  15. package/dist/cjs/hooks/use-engine.cjs +1 -0
  16. package/dist/cjs/hooks/use-field-state.cjs +1 -0
  17. package/dist/cjs/hooks/use-field-validator.cjs +1 -0
  18. package/dist/cjs/hooks/use-field-value.cjs +1 -0
  19. package/dist/cjs/hooks/use-form-config.cjs +1 -0
  20. package/dist/cjs/hooks/use-form-data.cjs +1 -0
  21. package/dist/cjs/hooks/use-form-submitting.cjs +1 -0
  22. package/dist/cjs/hooks/use-form.cjs +1 -0
  23. package/dist/cjs/hooks/use-nexus-context.cjs +1 -0
  24. package/dist/cjs/hooks/use-watch-all.cjs +1 -0
  25. package/dist/cjs/hooks/use-watch-multiple.cjs +1 -0
  26. package/dist/cjs/hooks/use-watch-state.cjs +1 -0
  27. package/dist/cjs/hooks/use-watch.cjs +1 -0
  28. package/dist/cjs/hooks/useFormData.cjs +1 -1
  29. package/dist/cjs/hooks/useFormSubmitting.cjs +1 -0
  30. package/dist/cjs/index.cjs +1 -1
  31. package/dist/cjs/styles.css +1 -1
  32. package/dist/cjs/utils/omit-nil.cjs +1 -0
  33. package/dist/cjs/utils/omitNil.cjs +1 -0
  34. package/dist/cjs/utils/persist.cjs +1 -0
  35. package/dist/cjs/utils/render-tree-node.cjs +1 -0
  36. package/dist/cjs/utils/resolve-col-span.cjs +1 -0
  37. package/dist/es/components/FormController.d.ts +73 -19
  38. package/dist/es/components/FormController.js +222 -71
  39. package/dist/es/components/NexusField.js +25 -21
  40. package/dist/es/components/NexusForm.d.ts +33 -3
  41. package/dist/es/components/NexusForm.js +143 -96
  42. package/dist/es/components/form-controller.d.ts +129 -0
  43. package/dist/es/components/form-controller.js +310 -0
  44. package/dist/es/components/nexus-field.d.ts +9 -0
  45. package/dist/es/components/nexus-field.js +131 -0
  46. package/dist/es/components/nexus-form-provider.d.ts +12 -0
  47. package/dist/es/components/nexus-form-provider.js +18 -0
  48. package/dist/es/components/nexus-form.d.ts +114 -0
  49. package/dist/es/components/nexus-form.js +174 -0
  50. package/dist/es/components/nexus-layout.d.ts +9 -0
  51. package/dist/es/components/nexus-layout.js +48 -0
  52. package/dist/es/components/nexus-object.d.ts +16 -0
  53. package/dist/es/components/nexus-object.js +64 -0
  54. package/dist/es/contexts/NexusContext.d.ts +2 -0
  55. package/dist/es/contexts/field-inherit-context.d.ts +18 -0
  56. package/dist/es/contexts/field-inherit-context.js +5 -0
  57. package/dist/es/contexts/grid-context.d.ts +5 -0
  58. package/dist/es/contexts/grid-context.js +5 -0
  59. package/dist/es/contexts/layout-config-context.d.ts +4 -0
  60. package/dist/es/contexts/layout-config-context.js +5 -0
  61. package/dist/es/contexts/nexus-context.d.ts +29 -0
  62. package/dist/es/contexts/nexus-context.js +12 -0
  63. package/dist/es/hooks/index.d.ts +12 -11
  64. package/dist/es/hooks/index.js +20 -18
  65. package/dist/es/hooks/use-engine.d.ts +2 -0
  66. package/dist/es/hooks/use-engine.js +8 -0
  67. package/dist/es/hooks/use-field-state.d.ts +7 -0
  68. package/dist/es/hooks/use-field-state.js +13 -0
  69. package/dist/es/hooks/use-field-validator.d.ts +40 -0
  70. package/dist/es/hooks/use-field-validator.js +35 -0
  71. package/dist/es/hooks/use-field-value.d.ts +6 -0
  72. package/dist/es/hooks/use-field-value.js +13 -0
  73. package/dist/es/hooks/use-form-config.d.ts +5 -0
  74. package/dist/es/hooks/use-form-config.js +8 -0
  75. package/dist/es/hooks/use-form-data.d.ts +6 -0
  76. package/dist/es/hooks/use-form-data.js +13 -0
  77. package/dist/es/hooks/use-form-submitting.d.ts +7 -0
  78. package/dist/es/hooks/use-form-submitting.js +11 -0
  79. package/dist/es/hooks/use-form.d.ts +22 -0
  80. package/dist/es/hooks/use-form.js +12 -0
  81. package/dist/es/hooks/use-nexus-context.d.ts +11 -0
  82. package/dist/es/hooks/use-nexus-context.js +12 -0
  83. package/dist/es/hooks/use-watch-all.d.ts +19 -0
  84. package/dist/es/hooks/use-watch-all.js +29 -0
  85. package/dist/es/hooks/use-watch-multiple.d.ts +20 -0
  86. package/dist/es/hooks/use-watch-multiple.js +29 -0
  87. package/dist/es/hooks/use-watch-state.d.ts +18 -0
  88. package/dist/es/hooks/use-watch-state.js +19 -0
  89. package/dist/es/hooks/use-watch.d.ts +25 -0
  90. package/dist/es/hooks/use-watch.js +20 -0
  91. package/dist/es/hooks/useForm.d.ts +11 -4
  92. package/dist/es/hooks/useFormData.js +9 -9
  93. package/dist/es/hooks/useFormSubmitting.d.ts +7 -0
  94. package/dist/es/hooks/useFormSubmitting.js +11 -0
  95. package/dist/es/index.d.ts +26 -24
  96. package/dist/es/index.js +36 -32
  97. package/dist/es/styles.css +1 -1
  98. package/dist/es/utils/omit-nil.d.ts +9 -0
  99. package/dist/es/utils/omit-nil.js +14 -0
  100. package/dist/es/utils/omitNil.d.ts +9 -0
  101. package/dist/es/utils/omitNil.js +14 -0
  102. package/dist/es/utils/persist.d.ts +24 -0
  103. package/dist/es/utils/persist.js +43 -0
  104. package/dist/es/utils/render-tree-node.d.ts +6 -0
  105. package/dist/es/utils/render-tree-node.js +17 -0
  106. package/dist/es/utils/resolve-col-span.d.ts +5 -0
  107. package/dist/es/utils/resolve-col-span.js +9 -0
  108. package/dist/umd/index.css +1 -1
  109. package/dist/umd/index.umd.cjs +1 -1
  110. package/package.json +5 -4
  111. package/src/components/form-controller.ts +584 -0
  112. package/src/components/{NexusField.tsx → nexus-field.tsx} +57 -6
  113. package/src/components/{NexusFormProvider.tsx → nexus-form-provider.tsx} +3 -3
  114. package/src/components/{NexusForm.tsx → nexus-form.tsx} +135 -18
  115. package/src/components/{NexusLayout.tsx → nexus-layout.tsx} +5 -5
  116. package/src/components/{NexusObject.tsx → nexus-object.tsx} +3 -3
  117. package/src/contexts/{NexusContext.ts → nexus-context.ts} +3 -1
  118. package/src/hooks/index.ts +12 -11
  119. package/src/hooks/{useEngine.ts → use-engine.ts} +1 -1
  120. package/src/hooks/{useFieldState.ts → use-field-state.ts} +1 -1
  121. package/src/hooks/{useFieldValidator.ts → use-field-validator.ts} +1 -1
  122. package/src/hooks/{useFieldValue.ts → use-field-value.ts} +1 -1
  123. package/src/hooks/{useFormConfig.ts → use-form-config.ts} +2 -2
  124. package/src/hooks/{useFormData.ts → use-form-data.ts} +4 -4
  125. package/src/hooks/use-form-submitting.ts +16 -0
  126. package/src/hooks/{useForm.ts → use-form.ts} +12 -5
  127. package/src/hooks/{useNexusContext.ts → use-nexus-context.ts} +2 -2
  128. package/src/index.ts +26 -24
  129. package/src/utils/omit-nil.ts +24 -0
  130. package/src/utils/persist.ts +88 -0
  131. package/src/utils/{renderTreeNode.tsx → render-tree-node.tsx} +3 -3
  132. package/src/utils/{resolveColSpan.ts → resolve-col-span.ts} +1 -1
  133. package/dist/cjs/tailwind.css +0 -1
  134. package/dist/es/tailwind.css +0 -1
  135. package/src/components/FormController.ts +0 -278
  136. /package/src/contexts/{FieldInheritContext.ts → field-inherit-context.ts} +0 -0
  137. /package/src/contexts/{GridContext.ts → grid-context.ts} +0 -0
  138. /package/src/contexts/{LayoutConfigContext.ts → layout-config-context.ts} +0 -0
  139. /package/src/hooks/{useWatchAll.ts → use-watch-all.ts} +0 -0
  140. /package/src/hooks/{useWatchMultiple.ts → use-watch-multiple.ts} +0 -0
  141. /package/src/hooks/{useWatchState.ts → use-watch-state.ts} +0 -0
  142. /package/src/hooks/{useWatch.ts → use-watch.ts} +0 -0
@@ -0,0 +1,584 @@
1
+ import type {
2
+ FieldState,
3
+ NexusFormInstance,
4
+ NexusSchema,
5
+ } from '@xbeeant/form-engine';
6
+ import { AsyncValidatorPlugin, NexusEngine } from '@xbeeant/form-engine';
7
+ import { omitNilDeep } from '../utils/omit-nil';
8
+
9
+ /** 已被某个 FormController 占用为「首个挂载视图」的引擎宿主(防止多 form 共享宿主时抢占 default 实例) */
10
+ const claimedHosts = new WeakSet<NexusEngine>();
11
+
12
+ /**
13
+ * FormController — 包裹引擎宿主,暴露 Form 实例 API
14
+ *
15
+ * 一个 form 可承载多个 NexusForm 挂载(每个挂载一份 schema,schema 与状态互相独立)。
16
+ * 实例标识由 NexusForm 内部生成(React useId),用户侧不感知任何 instanceId:
17
+ * - 同一 form 引用 = 同一引擎宿主:插件/组件注册/跨表单联动等引擎级能力共享
18
+ * - 每个 NexusForm 挂载自动获得独立实例:schema/值/校验/订阅互不影响
19
+ * - form 的 API(getValues/setValues/submit/resetFields...)聚合作用于全部实例
20
+ * - 引擎本身是独立实体,可单独创建或跨 form 复用(useForm(formId, engine))
21
+ */
22
+ export class FormController implements NexusFormInstance {
23
+ private engine: NexusEngine;
24
+ /** 提交中状态(校验 + onFinish 全流程,formily submitting 对齐) */
25
+ private submitting = false;
26
+ /** 提交状态监听器(供 useSyncExternalStore 消费) */
27
+ private submittingListeners = new Set<() => void>();
28
+ /** 实例标识(内部)→ 实例视图引擎(同一 form 挂载的多个 NexusForm 各自独立) */
29
+ private views: Map<string, NexusEngine> = new Map();
30
+ /** 实例标识 → 该实例的 DOM / 回调绑定(onFinish/onFinishFailed 按实例独立) */
31
+ private instanceBindings: Map<
32
+ string,
33
+ {
34
+ formEl: HTMLFormElement | null;
35
+ getOnFinish: () => (
36
+ data: Record<string, unknown>,
37
+ ) => void | Promise<void>;
38
+ getOnFinishFailed: () => (errors: Map<string, string[]>) => void;
39
+ }
40
+ > = new Map();
41
+ /** 实例标识 → watch / removeHiddenData / onValuesChange / omitNil 配置(按实例独立) */
42
+ private instanceConfigs: Map<
43
+ string,
44
+ {
45
+ removeHiddenData: boolean;
46
+ omitNil: boolean;
47
+ watchers: Map<
48
+ string,
49
+ (value: unknown, allValues: Record<string, unknown>) => void
50
+ >;
51
+ globalWatcher:
52
+ | ((
53
+ value: Record<string, unknown>,
54
+ allValues: Record<string, unknown>,
55
+ changedPath?: string,
56
+ ) => void)
57
+ | null;
58
+ /** x-render 对齐:值变更回调(changedValue, allValues, changedPath) */
59
+ onValuesChange:
60
+ | ((
61
+ changedValue: unknown,
62
+ allValues: Record<string, unknown>,
63
+ changedPath: string,
64
+ ) => void)
65
+ | null;
66
+ }
67
+ > = new Map();
68
+
69
+ constructor(engine?: NexusEngine) {
70
+ this.engine = engine ?? new NexusEngine();
71
+ // 默认注入异步校验器插件:useFieldValidator / registerFieldValidator
72
+ // 注册的异步校验器即可在字段值变化时被触发(防抖调度,与默认 'change' trigger 对齐)。
73
+ // 外部已注入同名插件时不重复注入(hasPlugin 幂等)。
74
+ if (!this.engine.hasPlugin('async-validator')) {
75
+ this.engine.use(new AsyncValidatorPlugin(this.engine));
76
+ }
77
+ }
78
+
79
+ /**
80
+ * 获取(或创建)指定实例的引擎视图
81
+ *
82
+ * 同一 form 挂载多个 NexusForm 时各自独立:schema/值/订阅互不影响。
83
+ * 实例标识由 NexusForm 内部分配(React useId),用户不感知。
84
+ *
85
+ * @param instanceId - 实例标识(内部)
86
+ * @returns 定向到该实例的引擎视图
87
+ */
88
+ _useInstance(instanceId: string): NexusEngine {
89
+ let view = this.views.get(instanceId);
90
+ if (!view) {
91
+ // 首个挂载复用宿主引擎(default 实例):渲染前对 form 的 API 调用
92
+ // (setValues/setSchema 等)落在宿主,挂载后即可见。
93
+ // 宿主被占用(其他 form 复用同一外部引擎)时不再抢占,改创建独立视图,
94
+ // 保证不同 form 共享宿主时实例状态互不影响。
95
+ if (this.views.size === 0 && !claimedHosts.has(this.engine)) {
96
+ claimedHosts.add(this.engine);
97
+ view = this.engine;
98
+ } else {
99
+ view = this.engine.instance(instanceId);
100
+ }
101
+ this.views.set(instanceId, view);
102
+ this.instanceConfigs.set(instanceId, {
103
+ removeHiddenData: true,
104
+ omitNil: false,
105
+ watchers: new Map(),
106
+ globalWatcher: null,
107
+ onValuesChange: null,
108
+ });
109
+ }
110
+ return view;
111
+ }
112
+
113
+ /**
114
+ * 内部:解析方法作用的目标实例视图(聚合全部实例,无实例时回退宿主引擎)
115
+ */
116
+ private resolveViews(): NexusEngine[] {
117
+ const views = Array.from(this.views.values());
118
+ return views.length > 0 ? views : [this.engine];
119
+ }
120
+
121
+ /** 内部:首次绑定的实例 DOM(兼容既有 scrollToPath/focusFirstError 单实例行为) */
122
+ private getPrimaryFormEl(): HTMLFormElement | null {
123
+ const first = this.instanceBindings.values().next().value;
124
+ return first?.formEl ?? null;
125
+ }
126
+
127
+ /**
128
+ * 内部:绑定实例 DOM + 回调 getter(由 NexusForm 在挂载时按实例调用)
129
+ * 回调以 getter 传入:submit 时读取最新闭包,而非绑定时刻的快照
130
+ */
131
+ _bind(
132
+ instanceId: string,
133
+ formEl: HTMLFormElement | null,
134
+ getOnFinish: () => (data: Record<string, unknown>) => void | Promise<void>,
135
+ getOnFinishFailed: () => (errors: Map<string, string[]>) => void,
136
+ ): void {
137
+ this.instanceBindings.set(instanceId, {
138
+ formEl,
139
+ getOnFinish,
140
+ getOnFinishFailed,
141
+ });
142
+ // 注册值变更回调到该实例引擎(多实例各自独立,回调携带实例标识路由 watch)
143
+ const view = this._useInstance(instanceId);
144
+ view.registerOnFieldValueChange((path, value) =>
145
+ this._onFieldValueChange(instanceId, path, value),
146
+ );
147
+ }
148
+
149
+ /** 内部:同步 watch / removeHiddenData / onValuesChange 配置(由 NexusForm 在它们变化时按实例调用) */
150
+ _syncConfig(
151
+ instanceId: string,
152
+ config: {
153
+ removeHiddenData?: boolean;
154
+ /** 提交/取值时递归移除空值(undefined/null/'',ProForm omitNil 对齐) */
155
+ omitNil?: boolean;
156
+ watch?: {
157
+ [path: string]: (
158
+ value: unknown,
159
+ allValues: Record<string, unknown>,
160
+ changedPath?: string,
161
+ ) => void;
162
+ };
163
+ onValuesChange?: (
164
+ changedValue: unknown,
165
+ allValues: Record<string, unknown>,
166
+ changedPath: string,
167
+ ) => void;
168
+ },
169
+ ): void {
170
+ const cfg = this.instanceConfigs.get(instanceId) ?? {
171
+ removeHiddenData: true,
172
+ omitNil: false,
173
+ watchers: new Map(),
174
+ globalWatcher: null,
175
+ onValuesChange: null,
176
+ };
177
+ if (config.removeHiddenData !== undefined) {
178
+ cfg.removeHiddenData = config.removeHiddenData;
179
+ }
180
+ if (config.omitNil !== undefined) {
181
+ cfg.omitNil = config.omitNil;
182
+ }
183
+ if (config.onValuesChange !== undefined) {
184
+ cfg.onValuesChange = config.onValuesChange;
185
+ }
186
+ if (config.watch) {
187
+ cfg.watchers.clear();
188
+ cfg.globalWatcher = null;
189
+ for (const [path, fn] of Object.entries(config.watch)) {
190
+ if (path === '#') {
191
+ cfg.globalWatcher = fn;
192
+ } else {
193
+ cfg.watchers.set(path, fn);
194
+ }
195
+ }
196
+ }
197
+ this.instanceConfigs.set(instanceId, cfg);
198
+ }
199
+
200
+ /** 内部:值变更时调用(由 Engine 通知,携带实例标识路由到对应实例的 watcher) */
201
+ _onFieldValueChange(instanceId: string, path: string, value: unknown): void {
202
+ const view = this.views.get(instanceId);
203
+ const cfg = this.instanceConfigs.get(instanceId);
204
+ if (!view || !cfg) {
205
+ return;
206
+ }
207
+ const allValues = view.getFormData();
208
+ const globalData = cfg.removeHiddenData ? allValues : view.getAllFormData();
209
+
210
+ // 全局 watcher(# 监听所有字段变化,value 即为全部表单值;
211
+ // 第三参携带本次实际变更的字段路径,供清空值场景区分「未赋值默认值」与「用户主动清空」)
212
+ if (cfg.globalWatcher) {
213
+ cfg.globalWatcher(globalData, globalData, path);
214
+ }
215
+ // x-render 对齐:onValuesChange(changedValue, allValues, changedPath)
216
+ if (cfg.onValuesChange) {
217
+ cfg.onValuesChange(value, globalData, path);
218
+ }
219
+ // 路径匹配的 watcher
220
+ const fn = cfg.watchers.get(path);
221
+ if (fn) {
222
+ fn(value, globalData);
223
+ }
224
+ }
225
+
226
+ /** 内部:获取引擎宿主实例 */
227
+ _getEngine(): NexusEngine {
228
+ return this.engine;
229
+ }
230
+
231
+ /**
232
+ * 获取底层引擎宿主实例(用于跨表单联动:linkForm / setFormId / registerAntdUI 等;
233
+ * 宿主上的组件/插件注册对全部实例生效)
234
+ */
235
+ getEngine(): NexusEngine {
236
+ return this.engine;
237
+ }
238
+
239
+ async submit(options?: {
240
+ validateFirst?: boolean;
241
+ omitNil?: boolean;
242
+ }): Promise<void> {
243
+ this.setSubmitting(true);
244
+ try {
245
+ await this.runSubmit(options);
246
+ } finally {
247
+ this.setSubmitting(false);
248
+ }
249
+ }
250
+
251
+ /** 提交中状态(formily submitting 对齐,供提交按钮 loading) */
252
+ getSubmitting(): boolean {
253
+ return this.submitting;
254
+ }
255
+
256
+ /** 订阅提交状态变化(返回取消订阅函数,供 useSyncExternalStore 使用) */
257
+ onSubmittingChange(callback: () => void): () => void {
258
+ this.submittingListeners.add(callback);
259
+ return () => {
260
+ this.submittingListeners.delete(callback);
261
+ };
262
+ }
263
+
264
+ private setSubmitting(value: boolean): void {
265
+ if (this.submitting === value) {
266
+ return;
267
+ }
268
+ this.submitting = value;
269
+ for (const listener of this.submittingListeners) {
270
+ listener();
271
+ }
272
+ }
273
+
274
+ private async runSubmit(options?: {
275
+ validateFirst?: boolean;
276
+ omitNil?: boolean;
277
+ }): Promise<void> {
278
+ const views = this.resolveViews();
279
+ // 汇总校验:任一实例失败即阻止提交
280
+ const allErrors = new Map<string, string[]>();
281
+ let failedBinding:
282
+ | {
283
+ formEl: HTMLFormElement | null;
284
+ getOnFinish: () => (
285
+ data: Record<string, unknown>,
286
+ ) => void | Promise<void>;
287
+ getOnFinishFailed: () => (errors: Map<string, string[]>) => void;
288
+ }
289
+ | undefined;
290
+ for (const view of views) {
291
+ const errors = await view.validate(undefined, {
292
+ validateFirst: options?.validateFirst,
293
+ });
294
+ if (errors.size > 0) {
295
+ for (const [path, messages] of errors) {
296
+ allErrors.set(path, messages);
297
+ }
298
+ failedBinding ??= this.instanceBindings.get(this.findInstanceId(view));
299
+ }
300
+ }
301
+ if (allErrors.size > 0) {
302
+ this.focusFirstError(allErrors, failedBinding?.formEl ?? null);
303
+ failedBinding?.getOnFinishFailed()?.(allErrors);
304
+ return;
305
+ }
306
+
307
+ // 全部通过:逐实例提交(插件 onSubmit 拦截 + 各自 onFinish)
308
+ for (const view of views) {
309
+ const id = this.findInstanceId(view);
310
+ const cfg = this.instanceConfigs.get(id) ?? {
311
+ removeHiddenData: true,
312
+ omitNil: false,
313
+ watchers: new Map(),
314
+ globalWatcher: null,
315
+ onValuesChange: null,
316
+ };
317
+ const formData = cfg.removeHiddenData
318
+ ? view.getFormData()
319
+ : view.getAllFormData();
320
+ // omitNil(ProForm 对齐):提交前递归移除空值
321
+ const shouldOmitNil = options?.omitNil ?? cfg.omitNil ?? false;
322
+ const finalData = (
323
+ shouldOmitNil ? omitNilDeep(formData) : formData
324
+ ) as Record<string, unknown>;
325
+ const allowed = await view.submit(finalData);
326
+ if (!allowed) {
327
+ continue;
328
+ }
329
+ await this.instanceBindings.get(id)?.getOnFinish()?.(finalData);
330
+ }
331
+ }
332
+
333
+ /** 内部:反查实例视图对应的实例标识 */
334
+ private findInstanceId(view: NexusEngine): string {
335
+ for (const [id, v] of this.views) {
336
+ if (v === view) {
337
+ return id;
338
+ }
339
+ }
340
+ return 'default';
341
+ }
342
+
343
+ /**
344
+ * 定位到第一个校验失败的字段:按 DOM 渲染顺序查找(保证视觉上的"第一个"),
345
+ * 滚动入视并聚焦其内部可交互控件。
346
+ */
347
+ private focusFirstError(
348
+ errors: Map<string, string[]>,
349
+ formEl: HTMLFormElement | null,
350
+ ): void {
351
+ if (!formEl || errors.size === 0) {
352
+ return;
353
+ }
354
+
355
+ const errorPaths = new Set(errors.keys());
356
+
357
+ const focus = () => {
358
+ const fieldEls =
359
+ formEl.querySelectorAll<HTMLElement>('[data-nexus-field]');
360
+ for (const el of Array.from(fieldEls)) {
361
+ const path = el.getAttribute('data-nexus-field');
362
+ if (!path || !errorPaths.has(path)) {
363
+ continue;
364
+ }
365
+
366
+ // jsdom 等无滚动实现的环境跳过滚动(仅聚焦)
367
+ if (typeof el.scrollIntoView === 'function') {
368
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
369
+ }
370
+ const focusable = el.querySelector<HTMLElement>(
371
+ 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
372
+ );
373
+ focusable?.focus();
374
+ return;
375
+ }
376
+ };
377
+
378
+ // 等待 React 将错误态刷到 DOM 后再定位,保证错误提示已可见
379
+ requestAnimationFrame(focus);
380
+ }
381
+
382
+ resetFields(): void {
383
+ for (const view of this.resolveViews()) {
384
+ view.reset();
385
+ }
386
+ }
387
+
388
+ setErrorFields(errors: Array<{ path: string; errors: string[] }>): void {
389
+ for (const view of this.resolveViews()) {
390
+ view.setErrorFields(errors);
391
+ }
392
+ }
393
+
394
+ setValues(values: Record<string, unknown>): void {
395
+ for (const view of this.resolveViews()) {
396
+ view.setFieldValues(values);
397
+ }
398
+ }
399
+
400
+ setValueByPath(path: string, value: unknown): void {
401
+ for (const view of this.resolveViews()) {
402
+ view.setFieldValue(path, value);
403
+ }
404
+ }
405
+
406
+ setSchemaByPath(path: string, patch: Record<string, unknown>): void {
407
+ for (const view of this.resolveViews()) {
408
+ view.setSchemaByPath(path, patch);
409
+ }
410
+ }
411
+
412
+ setSchema(schema: NexusSchema): void {
413
+ for (const view of this.resolveViews()) {
414
+ view.setSchema(schema);
415
+ }
416
+ }
417
+
418
+ getValues(
419
+ paths?: string[],
420
+ options?: { omitNil?: boolean },
421
+ ): Record<string, unknown> {
422
+ const merged: Record<string, unknown> = {};
423
+ for (const view of this.resolveViews()) {
424
+ const data = view.getFormData(paths);
425
+ // omitNil(ProForm 对齐):递归移除空值
426
+ if (options?.omitNil) {
427
+ Object.assign(merged, omitNilDeep(data) as Record<string, unknown>);
428
+ } else {
429
+ Object.assign(merged, data);
430
+ }
431
+ }
432
+ return merged;
433
+ }
434
+
435
+ getHiddenValues(): Record<string, unknown> {
436
+ const merged: Record<string, unknown> = {};
437
+ for (const view of this.resolveViews()) {
438
+ Object.assign(merged, view.getHiddenValues());
439
+ }
440
+ return merged;
441
+ }
442
+
443
+ /** 获取所有字段值(含 hidden) */
444
+ getAllValues(): Record<string, unknown> {
445
+ const merged: Record<string, unknown> = {};
446
+ for (const view of this.resolveViews()) {
447
+ Object.assign(merged, view.getAllFormData());
448
+ }
449
+ return merged;
450
+ }
451
+
452
+ getValueByPath(path: string): unknown {
453
+ for (const view of this.resolveViews()) {
454
+ const value = view.getFieldValue(path);
455
+ if (value !== undefined) {
456
+ return value;
457
+ }
458
+ }
459
+ return undefined;
460
+ }
461
+
462
+ /**
463
+ * 注册字段校验逻辑
464
+ * @param path 字段路径(如 'username')
465
+ * @param validator 校验函数,返回错误消息数组(空数组表示通过)
466
+ */
467
+ registerValidator(
468
+ path: string,
469
+ validator: (
470
+ value: unknown,
471
+ formData: Record<string, unknown>,
472
+ ) => string[] | Promise<string[]>,
473
+ ): void {
474
+ // 注册到各实例引擎(validate 与 实时校验 统一由 Engine 执行)
475
+ for (const view of this.resolveViews()) {
476
+ view.registerFieldValidator(path, validator);
477
+ }
478
+ }
479
+
480
+ /**
481
+ * 注销字段校验逻辑(按函数引用移除)
482
+ * 与 registerValidator 配对;widget 组件卸载时清理,避免校验器累积
483
+ */
484
+ unregisterValidator(
485
+ path: string,
486
+ validator: (
487
+ value: unknown,
488
+ formData: Record<string, unknown>,
489
+ ) => string[] | Promise<string[]>,
490
+ ): void {
491
+ for (const view of this.resolveViews()) {
492
+ view.unregisterFieldValidator(path, validator);
493
+ }
494
+ }
495
+
496
+ /**
497
+ * 实时重校验指定字段(同步)
498
+ * 供 widget 组件内部状态变化(非字段值变化)时主动刷新错误态
499
+ */
500
+ revalidateField(path: string): void {
501
+ for (const view of this.resolveViews()) {
502
+ view.validateField(path, { trigger: 'change' });
503
+ }
504
+ }
505
+
506
+ getSchema(): NexusSchema | null {
507
+ return this.resolveViews()[0]?.getSchema() ?? null;
508
+ }
509
+
510
+ removeErrorField(path: string): void {
511
+ for (const view of this.resolveViews()) {
512
+ view.removeErrorField(path);
513
+ }
514
+ }
515
+
516
+ scrollToPath(path: string): void {
517
+ const formEl = this.getPrimaryFormEl();
518
+ const el = formEl?.querySelector(`[data-nexus-field="${path}"]`);
519
+ el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
520
+ }
521
+
522
+ getFieldError(path: string): string[] {
523
+ for (const view of this.resolveViews()) {
524
+ const errors = view.getFieldError(path);
525
+ if (errors.length > 0) {
526
+ return errors;
527
+ }
528
+ }
529
+ return [];
530
+ }
531
+
532
+ getFieldsError(): Map<string, string[]> {
533
+ const merged = new Map<string, string[]>();
534
+ for (const view of this.resolveViews()) {
535
+ for (const [path, messages] of view.getFieldsError()) {
536
+ merged.set(path, messages);
537
+ }
538
+ }
539
+ return merged;
540
+ }
541
+
542
+ validateFields(
543
+ paths?: string[],
544
+ options?: { validateFirst?: boolean },
545
+ ): Promise<Map<string, string[]>> {
546
+ const views = this.resolveViews();
547
+ if (views.length === 1) {
548
+ return views[0].validate(paths, options);
549
+ }
550
+ // 多实例:并行校验并合并错误
551
+ return Promise.all(views.map((view) => view.validate(paths, options))).then(
552
+ (results) => {
553
+ const merged = new Map<string, string[]>();
554
+ for (const result of results) {
555
+ for (const [path, messages] of result) {
556
+ merged.set(path, messages);
557
+ }
558
+ }
559
+ return merged;
560
+ },
561
+ );
562
+ }
563
+
564
+ getFieldState(path: string): FieldState | undefined {
565
+ for (const view of this.resolveViews()) {
566
+ const state = view.getFieldState(path);
567
+ if (state) {
568
+ return state;
569
+ }
570
+ }
571
+ return undefined;
572
+ }
573
+
574
+ /**
575
+ * 重载远程选项数据(x-render reloadRemoteData 对齐)
576
+ * 传入 path 仅重载该字段的远程数据;缺省时重载全部远程数据字段。
577
+ * 作用于全部实例视图。
578
+ */
579
+ reloadRemoteData(path?: string): void {
580
+ for (const view of this.resolveViews()) {
581
+ view.reloadRemoteData(path);
582
+ }
583
+ }
584
+ }
@@ -1,10 +1,10 @@
1
1
  import type { CSSProperties, FocusEvent, ReactElement } from 'react';
2
2
  import { useCallback, useContext, useMemo, useSyncExternalStore } from 'react';
3
- import { FieldInheritContext } from '../contexts/FieldInheritContext';
4
- import { GridContext } from '../contexts/GridContext';
5
- import { LayoutConfigContext } from '../contexts/LayoutConfigContext';
6
- import { useNexusContext } from '../contexts/NexusContext';
7
- import { resolveColSpan } from '../utils/resolveColSpan';
3
+ import { FieldInheritContext } from '../contexts/field-inherit-context';
4
+ import { GridContext } from '../contexts/grid-context';
5
+ import { LayoutConfigContext } from '../contexts/layout-config-context';
6
+ import { useNexusContext } from '../contexts/nexus-context';
7
+ import { resolveColSpan } from '../utils/resolve-col-span';
8
8
 
9
9
  interface NexusFieldProps {
10
10
  dataPath: string;
@@ -82,6 +82,49 @@ export function NexusField({ dataPath, layoutKey }: NexusFieldProps) {
82
82
  return values;
83
83
  }, [state?.reactions, engine]);
84
84
 
85
+ // x-render addons — 为 widget 组件提供统一的表单数据访问、校验、Schema 操作入口
86
+ // 对齐 x-render 自定义组件的 addons API
87
+ const addons = useMemo(() => {
88
+ const arrayPath = state?.meta.itemOf;
89
+ const isItemField = !!arrayPath;
90
+ const indexMatch = isItemField ? dataPath.match(/\[(\d+)\]/) : undefined;
91
+ const index = indexMatch ? Number(indexMatch[1]) : undefined;
92
+
93
+ return {
94
+ get formData() {
95
+ return form.getValues();
96
+ },
97
+ get rootValue() {
98
+ return form.getValues();
99
+ },
100
+ value: state?.value,
101
+ dataPath,
102
+ path: dataPath,
103
+ index,
104
+ parentValues: arrayPath ? form.getValueByPath(arrayPath) : undefined,
105
+ getValue: (p: string) => form.getValueByPath(p),
106
+ setValue: (p: string, v: unknown) => form.setValueByPath(p, v),
107
+ onItemChange: (p: string, v: unknown) => form.setValueByPath(p, v),
108
+ validate: async (p?: string) => {
109
+ if (p) {
110
+ await form.validateFields([p]);
111
+ } else {
112
+ await form.validateFields([dataPath]);
113
+ }
114
+ },
115
+ validateFields: async (paths?: string[]) => {
116
+ await form.validateFields(paths);
117
+ },
118
+ submit: () => form.submit(),
119
+ resetFields: () => form.resetFields(),
120
+ setSchema: (s: Record<string, unknown>) =>
121
+ form.setSchema(s as any),
122
+ setSchemaByPath: (p: string, patch: Record<string, unknown>) =>
123
+ form.setSchemaByPath(p, patch),
124
+ getSchema: () => form.getSchema(),
125
+ };
126
+ }, [form, dataPath, state?.value, state?.meta.itemOf]);
127
+
85
128
  if (!state) {
86
129
  // 仅当引擎已初始化(version > 0)但字段仍未找到时才发出警告
87
130
  // 初始化过程中的短暂空状态不应报警
@@ -155,14 +198,17 @@ export function NexusField({ dataPath, layoutKey }: NexusFieldProps) {
155
198
  // - required: 会让 <input required> 触发浏览器原生校验
156
199
  // - errors/title/description/label/extra/width/displayType/labelWidth/column:
157
200
  // 作为未知属性透传到 DOM 会产生 React 警告
201
+ // width / colSpan / displayType / labelWidth / column 是布局属性,
202
+ // 由 NexusField 外层 <div> 的 wrapperStyle 统一消费,不透传给 FieldWrapper(Form.Item),
203
+ // 避免外层 div 与 Form.Item 重复设置 width。
158
204
  const fieldWrapperProps = {
159
205
  label: state.meta.label,
160
206
  title: state.meta.title,
161
207
  description: state.meta.description,
208
+ tooltip: state.meta.tooltip,
162
209
  errors: state.errors,
163
210
  required: state.required,
164
211
  extra: state.meta.extra,
165
- width: state.meta.width,
166
212
  displayType: fieldDisplayType,
167
213
  labelWidth: fieldLabelWidth,
168
214
  column: fieldColumn,
@@ -180,8 +226,12 @@ export function NexusField({ dataPath, layoutKey }: NexusFieldProps) {
180
226
  placeholder: state.meta.placeholder,
181
227
  options,
182
228
  form,
229
+ addons,
183
230
  dependValues,
184
231
  items: state.meta.items,
232
+ schema: state.meta.schema,
233
+ // 远程选项数据版本:reloadRemoteData 后变化,widget 据此跳过缓存重新请求
234
+ remoteVersion: engine.getRemoteDataVersion(dataPath),
185
235
  ...state.props,
186
236
  };
187
237
 
@@ -199,6 +249,7 @@ export function NexusField({ dataPath, layoutKey }: NexusFieldProps) {
199
249
  return (
200
250
  <div
201
251
  data-nexus-field={dataPath}
252
+ className={readOnly ? 'nexus-field-readonly' : undefined}
202
253
  onBlur={handleBlur}
203
254
  style={Object.keys(wrapperStyle).length > 0 ? wrapperStyle : undefined}
204
255
  >
@@ -1,9 +1,9 @@
1
1
  import type { NexusEngine } from '@xbeeant/form-engine';
2
2
  import type { ReactNode } from 'react';
3
3
  import { useMemo } from 'react';
4
- import { NexusContext } from '../contexts/NexusContext';
5
- import type { FormController } from './FormController';
6
- import type { NexusFormConfig } from './NexusForm';
4
+ import { NexusContext } from '../contexts/nexus-context';
5
+ import type { FormController } from './form-controller';
6
+ import type { NexusFormConfig } from './nexus-form';
7
7
 
8
8
  interface NexusFormProviderProps {
9
9
  engine: NexusEngine;