react-extjs 0.0.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +36 -0
  3. package/src/components/AbsoluteLayout.tsx +206 -0
  4. package/src/components/Accordion.tsx +266 -0
  5. package/src/components/AnchorLayout.tsx +131 -0
  6. package/src/components/BorderLayout.tsx +512 -0
  7. package/src/components/Box.tsx +289 -0
  8. package/src/components/Button.tsx +300 -0
  9. package/src/components/ButtonGroup.tsx +170 -0
  10. package/src/components/CalendarPicker.tsx +431 -0
  11. package/src/components/CardLayout.tsx +122 -0
  12. package/src/components/CenterLayout.tsx +95 -0
  13. package/src/components/CheckboxGroup.tsx +200 -0
  14. package/src/components/ColorMenu.tsx +195 -0
  15. package/src/components/ColorPalette.tsx +126 -0
  16. package/src/components/ColumnLayout.tsx +183 -0
  17. package/src/components/ColumnTree.tsx +383 -0
  18. package/src/components/CycleButton.tsx +302 -0
  19. package/src/components/DataView.tsx +244 -0
  20. package/src/components/DataViewMore.tsx +337 -0
  21. package/src/components/DataViewTransition.tsx +235 -0
  22. package/src/components/DateMenu.tsx +163 -0
  23. package/src/components/DatePicker.tsx +387 -0
  24. package/src/components/DisplayField.tsx +73 -0
  25. package/src/components/Editor.tsx +214 -0
  26. package/src/components/EditorGrid.tsx +361 -0
  27. package/src/components/FieldLabeler.tsx +81 -0
  28. package/src/components/FieldReplicator.tsx +135 -0
  29. package/src/components/FieldSet.tsx +149 -0
  30. package/src/components/FitLayout.tsx +76 -0
  31. package/src/components/Form.tsx +3484 -0
  32. package/src/components/Grid.tsx +2173 -0
  33. package/src/components/GridFilters.tsx +332 -0
  34. package/src/components/GroupTab.tsx +274 -0
  35. package/src/components/HtmlEditor.tsx +391 -0
  36. package/src/components/ImageOrganizer.tsx +984 -0
  37. package/src/components/ImageOrganizerService.ts +361 -0
  38. package/src/components/ListView.tsx +221 -0
  39. package/src/components/LoadMask.tsx +74 -0
  40. package/src/components/Menu.tsx +227 -0
  41. package/src/components/MessageBox.tsx +138 -0
  42. package/src/components/MultiSlider.tsx +369 -0
  43. package/src/components/Organizer.tsx +403 -0
  44. package/src/components/PagingToolbar.tsx +255 -0
  45. package/src/components/Panel.tsx +451 -0
  46. package/src/components/ProgressBar.tsx +127 -0
  47. package/src/components/PropertyGrid.tsx +142 -0
  48. package/src/components/QuickTip.tsx +174 -0
  49. package/src/components/Resizable.tsx +241 -0
  50. package/src/components/RowLayout.tsx +126 -0
  51. package/src/components/Slider.tsx +353 -0
  52. package/src/components/SliderField.tsx +206 -0
  53. package/src/components/SpinnerField.tsx +141 -0
  54. package/src/components/SplitBar.tsx +215 -0
  55. package/src/components/Spotlight.tsx +262 -0
  56. package/src/components/StatusBar.tsx +118 -0
  57. package/src/components/TabPanel.tsx +540 -0
  58. package/src/components/TableLayout.tsx +132 -0
  59. package/src/components/ThemeSwitcher.tsx +167 -0
  60. package/src/components/ToolTip.tsx +226 -0
  61. package/src/components/Toolbar.tsx +321 -0
  62. package/src/components/Tree.tsx +809 -0
  63. package/src/components/TreeGrid.tsx +418 -0
  64. package/src/components/Viewport.tsx +218 -0
  65. package/src/components/Window.tsx +441 -0
  66. package/src/components/Writer.tsx +303 -0
  67. package/src/components/XmlTreeLoader.tsx +233 -0
  68. package/src/components/dnd/DndContext.tsx +328 -0
  69. package/src/components/dnd/DragSource.tsx +202 -0
  70. package/src/components/dnd/DragZone.tsx +66 -0
  71. package/src/components/dnd/DropTarget.tsx +86 -0
  72. package/src/components/dnd/DropZone.tsx +166 -0
  73. package/src/components/dnd/PanelProxy.tsx +81 -0
  74. package/src/components/dnd/PanelResizer.tsx +143 -0
  75. package/src/components/dnd/Portal.tsx +254 -0
  76. package/src/components/dnd/Reorderer.tsx +73 -0
  77. package/src/components/dnd/ToolbarReorderer.tsx +226 -0
  78. package/src/main.tsx +2649 -0
  79. package/src/styles/ext-theme.css +889 -0
  80. package/src/util/theme.ts +95 -0
@@ -0,0 +1,3484 @@
1
+ /**
2
+ * Ext.form.* 保真复刻(来源 src/widgets/form + src/widgets/layout/FormLayout.js)
3
+ *
4
+ * 保真契约:
5
+ * - FormLayout.fieldTpl(217-223 行):x-form-item > label.x-form-item-label + div.x-form-element
6
+ * - Field.onRender:input 带 x-form-field x-form-{type}(type=text → x-form-text)
7
+ * - Checkbox.onRender:x-form-check-wrap > input.x-form-checkbox + label.x-form-cb-label
8
+ * - TriggerField.onRender:x-form-field-wrap x-form-field-trigger-wrap > input.x-form-text + img.x-form-trigger
9
+ * React 仅重写行为与状态,DOM/class 与 Ext JS 3.2.1 一致,复用 ext-all.css 得到像素级还原。
10
+ */
11
+ import { useState, useCallback, useEffect, useRef, useMemo, useLayoutEffect, useImperativeHandle, forwardRef, cloneElement, Children, isValidElement, Fragment, type ReactElement, type ReactNode, type CSSProperties } from 'react';
12
+ import { createPortal } from 'react-dom';
13
+ import { Panel } from './Panel';
14
+
15
+ // 浮层 portal 目标:必须位于 React 根容器 DOM 子树内。
16
+ // 原因:React 18 把事件委托监听器挂在根容器上;若把下拉/日历浮层 createPortal 到
17
+ // document.body(根容器的兄弟节点),原生点击事件冒泡到 body 时并不经过根容器,
18
+ // 导致浮层内选项的 React onClick 永远不触发、下拉「无法选择」。
19
+ // 列表本身使用 position:fixed 视口定位,挂在内层覆盖 div 不会被 overflow 裁剪。
20
+ let __portalRoot: HTMLElement | null = null;
21
+ function getPortalRoot(): HTMLElement {
22
+ if (__portalRoot && document.body.contains(__portalRoot)) return __portalRoot;
23
+ const host = document.getElementById('docs-root') || document.getElementById('root') || document.body;
24
+ const el = document.createElement('div');
25
+ el.className = 'x-portal-root';
26
+ host.appendChild(el);
27
+ __portalRoot = el;
28
+ return el;
29
+ }
30
+ import { DatePicker } from './DatePicker';
31
+ import { Button } from './Button';
32
+
33
+ const BLANK_IMG = '/extjs-assets/images/default/s.gif';
34
+
35
+ /* ------------------------------------------------------------------ */
36
+ /* FormField —— 复刻 FormLayout.fieldTpl 的字段包裹结构 */
37
+ /* ------------------------------------------------------------------ */
38
+ export interface FormFieldProps {
39
+ id?: string;
40
+ fieldLabel?: ReactNode;
41
+ labelAlign?: 'left' | 'top' | 'right';
42
+ labelWidth?: number;
43
+ labelPad?: number;
44
+ labelSeparator?: string;
45
+ hideLabel?: boolean;
46
+ itemCls?: string;
47
+ children?: ReactNode;
48
+ style?: CSSProperties;
49
+ }
50
+
51
+ export function FormField({
52
+ id,
53
+ fieldLabel,
54
+ labelAlign = 'left',
55
+ labelWidth = 100,
56
+ labelPad = 5,
57
+ labelSeparator = ':',
58
+ hideLabel,
59
+ itemCls = '',
60
+ children,
61
+ style,
62
+ }: FormFieldProps) {
63
+ const isTop = labelAlign === 'top';
64
+ const labelStyle: CSSProperties = isTop
65
+ ? { width: 'auto' }
66
+ : { width: `${labelWidth}px` };
67
+ const elementStyle: CSSProperties = isTop
68
+ ? { paddingLeft: 0 }
69
+ : { paddingLeft: `${labelWidth + labelPad}px` };
70
+
71
+ const itemClass = [
72
+ 'x-form-item',
73
+ itemCls,
74
+ hideLabel ? 'x-hide-label' : '',
75
+ `x-form-label-${labelAlign}`,
76
+ ]
77
+ .filter(Boolean)
78
+ .join(' ');
79
+
80
+ // 把 id 透传给唯一子输入(Ext 中 field.id 落在 input 上,label htmlFor 才能关联)
81
+ const child = Children.count(children) === 1 ? (Children.toArray(children)[0] as ReactElement) : null;
82
+ const childWithId =
83
+ id && child && child.props && (child.props as { id?: string }).id === undefined
84
+ ? cloneElement(child, { id })
85
+ : children;
86
+
87
+ return (
88
+ <div className={itemClass} tabIndex={-1} style={style}>
89
+ {!hideLabel && fieldLabel != null && (
90
+ <label htmlFor={id} style={labelStyle} className="x-form-item-label">
91
+ {fieldLabel}
92
+ {labelSeparator}
93
+ </label>
94
+ )}
95
+ <div className="x-form-element" id={id ? `x-form-el-${id}` : undefined} style={elementStyle}>
96
+ {childWithId}
97
+ </div>
98
+ <div className="x-form-clear-left"></div>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ /* ------------------------------------------------------------------ */
104
+ /* 输入类字段基类(text / number 共用) */
105
+ /* ------------------------------------------------------------------ */
106
+ /** 校验相关配置(对齐 Ext.form.Field 的 allowBlank / validator / invalidText / msgTarget) */
107
+ export interface FieldValidationProps {
108
+ /** 是否允许为空,默认 true。false 时空值即无效。 */
109
+ allowBlank?: boolean;
110
+ /** allowBlank=false 时空值报错文案。 */
111
+ /** 自定义校验:返回 true 表示通过;返回字符串表示错误信息(该字符串即为错误提示)。 */
112
+ validator?: (value: string) => boolean | string;
113
+ /** 默认错误提示(allowBlank=false 时空值使用,或被 validator 返回的 true 之外的情况兜底)。 */
114
+ invalidText?: string;
115
+ /** 错误展示位置:'side'(字段右侧红色感叹号图标,默认)/ 'under'(字段下方红色文字)。 */
116
+ msgTarget?: 'side' | 'under';
117
+ /** 失焦时是否自动校验,默认 true(对齐 Ext Field 默认行为)。 */
118
+ validateOnBlur?: boolean;
119
+ /** 最小输入长度(默认 0)。 */
120
+ minLength?: number;
121
+ /** 最大输入长度(默认 524288,仅校验不硬性截断)。 */
122
+ maxLength?: number;
123
+ /** 最小长度校验失败文案({0} 替换为阈值)。 */
124
+ minLengthText?: string;
125
+ /** 最大长度校验失败文案({0} 替换为阈值)。 */
126
+ maxLengthText?: string;
127
+ /** 自定义正则,失败报 regexText。 */
128
+ regex?: RegExp;
129
+ /** 正则校验失败文案。 */
130
+ regexText?: string;
131
+ /** 内置校验类型 email / url / alpha / alphanum(对齐 Ext VTypes)。 */
132
+ vtype?: 'email' | 'url' | 'alpha' | 'alphanum';
133
+ /** 覆盖 vtype 默认错误文案。 */
134
+ vtypeText?: string;
135
+ /** 按键过滤正则,不匹配字符被拦截(对齐 Ext maskRe)。 */
136
+ maskRe?: RegExp;
137
+ /** 输入后剔除字符正则(对齐 Ext stripCharsRe)。 */
138
+ stripCharsRe?: RegExp;
139
+ /** 聚焦时全选文本(对齐 Ext selectOnFocus,默认 false)。 */
140
+ selectOnFocus?: boolean;
141
+ /** 随内容自动增长宽度(对齐 Ext grow,默认 false)。 */
142
+ grow?: boolean;
143
+ /** grow 时最小宽度(默认 30)。 */
144
+ growMin?: number;
145
+ /** grow 时最大宽度(默认 800)。 */
146
+ growMax?: number;
147
+ }
148
+
149
+ /** 通过 ref 暴露的命令式校验接口(对齐 Ext form.Field.validate / clearInvalid / reset / setValue / selectText 等)。 */
150
+ export interface TextFieldHandle {
151
+ /** 执行校验:更新无效态并返回是否通过。 */
152
+ validate: () => boolean;
153
+ /** 清除无效态。 */
154
+ clearInvalid: () => void;
155
+ /** 当前是否已通过校验(不更新错误态)。 */
156
+ isValid: () => boolean;
157
+ /** 重置为初始值并清除错误态(对齐 Ext reset)。 */
158
+ reset: () => void;
159
+ /** 设置值(对齐 Ext setValue,并触发 onChange)。 */
160
+ setValue: (v: string) => void;
161
+ /** 获取当前值(对齐 Ext getValue)。 */
162
+ getValue: () => string;
163
+ /** 选中文本 start..end(对齐 Ext selectText)。 */
164
+ selectText: (start?: number, end?: number) => void;
165
+ /** 聚焦输入框。 */
166
+ focus: () => void;
167
+ }
168
+
169
+ interface BaseInputProps extends FieldValidationProps {
170
+ id?: string;
171
+ name?: string;
172
+ value?: string;
173
+ defaultValue?: string;
174
+ emptyText?: string;
175
+ disabled?: boolean;
176
+ readOnly?: boolean;
177
+ width?: number;
178
+ cls?: string;
179
+ inputType?: string;
180
+ style?: CSSProperties;
181
+ onChange?: (value: string) => void;
182
+ onFocus?: () => void;
183
+ onBlur?: () => void;
184
+ }
185
+
186
+ function useFieldValue(initial?: string, defaultValue?: string) {
187
+ const [value, setValue] = useState(initial ?? defaultValue ?? '');
188
+ return [value, setValue] as const;
189
+ }
190
+
191
+ /** 内置 VTypes(对齐 Ext.form.VTypes 的 email / url / alpha / alphanum)。 */
192
+ const VTYPES: Record<string, { re: RegExp; text: string }> = {
193
+ email: {
194
+ re: /^([\w]+)([-+.][\w]+)*@([\w-]+\.)+[a-zA-Z]{2,7}$/,
195
+ text: 'This field should be an e-mail address in the format "user@domain.com"',
196
+ },
197
+ url: {
198
+ re: /(((^https?)|(^ftp)):\/\/(((\w+\.)?)+)?(\w*)((:(\d+))?)(\/([\w#!:.?+=&%@!\-\/])?)?)/i,
199
+ text: 'This field should be a URL in the format "http:/' + '/www.domain.com"',
200
+ },
201
+ alpha: { re: /^[a-zA-Z_]+$/, text: 'This field should only contain letters and _' },
202
+ alphanum: { re: /^[a-zA-Z0-9_]+$/, text: 'This field should only contain letters, numbers and _' },
203
+ };
204
+
205
+ /** 依据当前值计算错误文案(null 表示通过)。对齐 Ext Field.getErrors / TextField.getErrors 的校验链路。 */
206
+ export function computeError(
207
+ value: string,
208
+ opts: FieldValidationProps,
209
+ ): string | null {
210
+ const v = value ?? '';
211
+ const isEmpty = v.length < 1;
212
+
213
+ if (typeof opts.validator === 'function') {
214
+ const r = opts.validator(v);
215
+ if (r !== true) return typeof r === 'string' ? r : opts.invalidText ?? '输入值无效';
216
+ }
217
+ if (isEmpty) {
218
+ if (opts.allowBlank === false) return opts.invalidText ?? '该输入为必填项';
219
+ return null;
220
+ }
221
+ if (typeof opts.minLength === 'number' && v.length < opts.minLength) {
222
+ return (opts.minLengthText ?? 'The minimum length for this field is {0}').replace(
223
+ '{0}',
224
+ String(opts.minLength),
225
+ );
226
+ }
227
+ if (typeof opts.maxLength === 'number' && v.length > opts.maxLength) {
228
+ return (opts.maxLengthText ?? 'The maximum length for this field is {0}').replace(
229
+ '{0}',
230
+ String(opts.maxLength),
231
+ );
232
+ }
233
+ if (opts.vtype && VTYPES[opts.vtype] && !VTYPES[opts.vtype].re.test(v)) {
234
+ return opts.vtypeText ?? VTYPES[opts.vtype].text;
235
+ }
236
+ if (opts.regex && !opts.regex.test(v)) {
237
+ return opts.regexText ?? '输入值格式不正确';
238
+ }
239
+ return null;
240
+ }
241
+
242
+ const TextInput = forwardRef<TextFieldHandle, BaseInputProps & { elementClass: string }>(
243
+ function TextInput(
244
+ {
245
+ id,
246
+ name,
247
+ value,
248
+ defaultValue,
249
+ emptyText,
250
+ disabled,
251
+ readOnly,
252
+ width,
253
+ cls = '',
254
+ inputType = 'text',
255
+ elementClass,
256
+ style,
257
+ onChange,
258
+ onFocus,
259
+ onBlur,
260
+ allowBlank,
261
+ validator,
262
+ invalidText,
263
+ msgTarget = 'side',
264
+ validateOnBlur = true,
265
+ minLength,
266
+ maxLength,
267
+ minLengthText,
268
+ maxLengthText,
269
+ regex,
270
+ regexText,
271
+ vtype,
272
+ vtypeText,
273
+ maskRe,
274
+ stripCharsRe,
275
+ selectOnFocus = false,
276
+ grow = false,
277
+ growMin = 30,
278
+ growMax = 800,
279
+ },
280
+ ref,
281
+ ) {
282
+ const [focused, setFocused] = useState(false);
283
+ const [internal, setInternal] = useFieldValue(value, defaultValue);
284
+ const [error, setError] = useState<string | null>(null);
285
+ const current = value !== undefined ? value : internal;
286
+ const inputRef = useRef<HTMLInputElement>(null);
287
+ const iconRef = useRef<HTMLDivElement>(null);
288
+ const initialRef = useRef<string>(value ?? defaultValue ?? '');
289
+
290
+ const validationOpts: FieldValidationProps = {
291
+ allowBlank,
292
+ validator,
293
+ invalidText,
294
+ minLength,
295
+ maxLength,
296
+ minLengthText,
297
+ maxLengthText,
298
+ regex,
299
+ regexText,
300
+ vtype,
301
+ vtypeText,
302
+ };
303
+
304
+ const validate = useCallback((): boolean => {
305
+ const e = computeError(current, validationOpts);
306
+ setError(e);
307
+ return e === null;
308
+ }, [current, validationOpts]); // eslint-disable-line react-hooks/exhaustive-deps
309
+
310
+ useImperativeHandle(
311
+ ref,
312
+ () => ({
313
+ validate,
314
+ clearInvalid: () => setError(null),
315
+ isValid: () => computeError(current, validationOpts) === null,
316
+ reset: () => {
317
+ setInternal(initialRef.current);
318
+ setError(null);
319
+ },
320
+ setValue: (v: string) => {
321
+ if (value === undefined) setInternal(v);
322
+ onChange?.(v);
323
+ },
324
+ getValue: () => current,
325
+ selectText: (start = 0, end = current.length) => {
326
+ const el = inputRef.current;
327
+ if (!el) return;
328
+ el.focus();
329
+ try {
330
+ el.setSelectionRange(start, end);
331
+ } catch {
332
+ /* IE 分支省略 */
333
+ }
334
+ },
335
+ focus: () => inputRef.current?.focus(),
336
+ }),
337
+ [validate, current, validationOpts, value, onChange], // eslint-disable-line react-hooks/exhaustive-deps
338
+ );
339
+
340
+ // 已标红时,值变化即时重校验(编辑中清除/刷新错误,对齐 Ext 字段校验联动行为)。
341
+ useEffect(() => {
342
+ setError((prev) => (prev === null ? prev : computeError(current, validationOpts)));
343
+ }, [current, validationOpts]); // eslint-disable-line react-hooks/exhaustive-deps
344
+
345
+ // side 模式:感叹号图标需定位到 input 右缘 +2(对齐 Ext alignErrorIcon 的 tl-tr [2,0])。
346
+ useLayoutEffect(() => {
347
+ if (error && msgTarget === 'side' && inputRef.current && iconRef.current) {
348
+ const inp = inputRef.current;
349
+ iconRef.current.style.left = `${inp.offsetLeft + inp.offsetWidth + 2}px`;
350
+ iconRef.current.style.top = `${inp.offsetTop}px`;
351
+ }
352
+ }, [error, msgTarget, current]);
353
+
354
+ const isEmpty = current.length === 0;
355
+ const inputClass = [
356
+ 'x-form-field',
357
+ elementClass,
358
+ cls,
359
+ focused ? 'x-form-focus' : '',
360
+ !focused && isEmpty && emptyText ? 'x-form-empty-field' : '',
361
+ error ? 'x-form-invalid' : '',
362
+ ]
363
+ .filter(Boolean)
364
+ .join(' ');
365
+
366
+ // grow:随内容调整宽度(对齐 Ext grow / growMin / growMax)
367
+ let effWidth = width;
368
+ if (grow) {
369
+ effWidth = Math.min(growMax, Math.max((current.length || 1) * 7 + 12, growMin));
370
+ }
371
+
372
+ const handleFocus = useCallback(() => {
373
+ setFocused(true);
374
+ if (selectOnFocus && inputRef.current) inputRef.current.select();
375
+ onFocus?.();
376
+ }, [selectOnFocus, onFocus]);
377
+
378
+ const handleBlur = useCallback(() => {
379
+ setFocused(false);
380
+ onBlur?.();
381
+ if (validateOnBlur) validate();
382
+ }, [onBlur, validateOnBlur, validate]);
383
+
384
+ const handleKeyDown = useCallback(
385
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
386
+ if (maskRe && e.key.length === 1 && !maskRe.test(e.key)) {
387
+ e.preventDefault();
388
+ }
389
+ },
390
+ [maskRe],
391
+ );
392
+
393
+ const handleChange = useCallback(
394
+ (e: React.ChangeEvent<HTMLInputElement>) => {
395
+ let v = e.target.value;
396
+ if (stripCharsRe) v = v.replace(stripCharsRe, '');
397
+ if (value === undefined) setInternal(v);
398
+ onChange?.(v);
399
+ },
400
+ [value, onChange, stripCharsRe],
401
+ );
402
+
403
+ return (
404
+ <>
405
+ <input
406
+ ref={inputRef}
407
+ id={id}
408
+ name={name}
409
+ type={inputType}
410
+ className={inputClass}
411
+ value={current}
412
+ placeholder={emptyText}
413
+ disabled={disabled}
414
+ readOnly={readOnly}
415
+ style={{ height: 22, ...(effWidth !== undefined ? { width: effWidth } : {}), ...style }}
416
+ onFocus={handleFocus}
417
+ onBlur={handleBlur}
418
+ onKeyDown={handleKeyDown}
419
+ onChange={handleChange}
420
+ />
421
+ {error && msgTarget === 'side' && (
422
+ <div
423
+ ref={iconRef}
424
+ className="x-form-invalid-icon"
425
+ title={error}
426
+ style={{ visibility: 'visible' }}
427
+ />
428
+ )}
429
+ {error && msgTarget === 'under' && (
430
+ <div className="x-form-invalid-msg" style={{ display: 'block' }}>
431
+ {error}
432
+ </div>
433
+ )}
434
+ </>
435
+ );
436
+ },
437
+ );
438
+
439
+ export interface TextFieldProps extends BaseInputProps {}
440
+ export const TextField = forwardRef<TextFieldHandle, TextFieldProps>((props, ref) => (
441
+ <TextInput ref={ref} {...props} elementClass="x-form-text" />
442
+ ));
443
+
444
+ export interface NumberFieldProps extends Omit<BaseInputProps, 'inputType'> {
445
+ /** 是否允许小数(默认 true)。false 时拦截并剔除小数点。 */
446
+ allowDecimals?: boolean;
447
+ /** 小数分隔符(默认 '.')。 */
448
+ decimalSeparator?: string;
449
+ /** 显示小数位精度(默认 2,-1 表示不限制)。失焦时按此精度四舍五入。 */
450
+ decimalPrecision?: number;
451
+ /** 是否允许负号(默认 true)。false 时拦截并剔除 '-'。 */
452
+ allowNegative?: boolean;
453
+ /** 最小值(默认 -Infinity)。 */
454
+ minValue?: number;
455
+ /** 最大值(默认 +Infinity)。 */
456
+ maxValue?: number;
457
+ /** 小于最小值时报文({0} 替换为阈值)。 */
458
+ minText?: string;
459
+ /** 大于最大值时报文({0} 替换为阈值)。 */
460
+ maxText?: string;
461
+ /** 非有效数字时报文({0} 替换为原输入)。 */
462
+ nanText?: string;
463
+ /** allowBlank=false 时空值报错文案。 */
464
+ blankText?: string;
465
+ /** 合法字符基集(默认 '0123456789')。与 decimalSeparator / '-'(若允许)共同构成自动 maskRe。 */
466
+ baseChars?: string;
467
+ }
468
+
469
+ /** 数值字段校验:解析失败 / 越界 / 必填空值 / 自定义 validator,返回错误文案或 null。 */
470
+ function computeNumberError(
471
+ raw: string,
472
+ o: {
473
+ allowBlank?: boolean;
474
+ blankText?: string;
475
+ validator?: (v: string) => boolean | string;
476
+ invalidText?: string;
477
+ minValue: number;
478
+ maxValue: number;
479
+ minText?: string;
480
+ maxText?: string;
481
+ nanText?: string;
482
+ decimalSeparator: string;
483
+ },
484
+ ): string | null {
485
+ const v = (raw ?? '').trim();
486
+ if (o.allowBlank === false && v === '') {
487
+ return o.blankText ?? '该输入为必填项';
488
+ }
489
+ if (v === '') return null;
490
+ const num = parseFloat(v.replace(o.decimalSeparator, '.'));
491
+ if (isNaN(num)) {
492
+ return (o.nanText ?? '{0} 不是有效数字').replace('{0}', v);
493
+ }
494
+ if (num < o.minValue) {
495
+ return (o.minText ?? '最小值为 {0}').replace('{0}', String(o.minValue));
496
+ }
497
+ if (num > o.maxValue) {
498
+ return (o.maxText ?? '最大值为 {0}').replace('{0}', String(o.maxValue));
499
+ }
500
+ if (o.validator) {
501
+ const r = o.validator(v);
502
+ if (r !== true) return typeof r === 'string' ? r : o.invalidText ?? '输入值无效';
503
+ }
504
+ return null;
505
+ }
506
+
507
+ /** 按 decimalPrecision 四舍五入修正显示值(对齐 Ext fixPrecision + setValue)。 */
508
+ function fixPrecision(
509
+ raw: string,
510
+ allowDecimals: boolean,
511
+ decimalPrecision: number,
512
+ decimalSeparator: string,
513
+ ): string {
514
+ if (!allowDecimals || decimalPrecision === -1 || raw.trim() === '') return raw;
515
+ const num = parseFloat(raw.replace(decimalSeparator, '.'));
516
+ if (isNaN(num)) return raw;
517
+ const fixed = parseFloat(num.toFixed(decimalPrecision));
518
+ return String(fixed).replace('.', decimalSeparator);
519
+ }
520
+
521
+ export const NumberField = forwardRef<TextFieldHandle, NumberFieldProps>((props, ref) => {
522
+ const {
523
+ allowDecimals = true,
524
+ decimalSeparator = '.',
525
+ decimalPrecision = 2,
526
+ allowNegative = true,
527
+ minValue = Number.NEGATIVE_INFINITY,
528
+ maxValue = Number.MAX_VALUE,
529
+ minText,
530
+ maxText,
531
+ nanText,
532
+ baseChars = '0123456789',
533
+ allowBlank,
534
+ validator,
535
+ invalidText,
536
+ blankText,
537
+ maskRe,
538
+ msgTarget = 'side',
539
+ validateOnBlur = true,
540
+ } = props;
541
+ const [focused, setFocused] = useState(false);
542
+ const [internal, setInternal] = useFieldValue(props.value, props.defaultValue);
543
+ const [error, setError] = useState<string | null>(null);
544
+ const current = props.value !== undefined ? props.value : internal;
545
+ const inputRef = useRef<HTMLInputElement>(null);
546
+ const iconRef = useRef<HTMLDivElement>(null);
547
+ const initialRef = useRef<string>(props.value ?? props.defaultValue ?? '');
548
+
549
+ const vProps = {
550
+ allowBlank,
551
+ blankText,
552
+ validator,
553
+ invalidText,
554
+ minValue,
555
+ maxValue,
556
+ minText,
557
+ maxText,
558
+ nanText,
559
+ decimalSeparator,
560
+ };
561
+
562
+ const validate = useCallback((): boolean => {
563
+ const e = computeNumberError(current, vProps);
564
+ setError(e);
565
+ return e === null;
566
+ }, [current, allowBlank, blankText, validator, invalidText, minValue, maxValue, minText, maxText, nanText, decimalSeparator]);
567
+
568
+ useImperativeHandle(
569
+ ref,
570
+ () => ({
571
+ validate,
572
+ clearInvalid: () => setError(null),
573
+ isValid: () => computeNumberError(current, vProps) === null,
574
+ reset: () => {
575
+ setInternal(initialRef.current);
576
+ setError(null);
577
+ },
578
+ setValue: (v: string) => {
579
+ const fixed = fixPrecision(v, allowDecimals, decimalPrecision, decimalSeparator);
580
+ if (props.value === undefined) setInternal(fixed);
581
+ props.onChange?.(fixed);
582
+ },
583
+ getValue: () => current,
584
+ selectText: (start = 0, end = current.length) => {
585
+ const el = inputRef.current;
586
+ if (!el) return;
587
+ el.focus();
588
+ try {
589
+ el.setSelectionRange(start, end);
590
+ } catch {
591
+ /* IE 分支省略 */
592
+ }
593
+ },
594
+ focus: () => inputRef.current?.focus(),
595
+ }),
596
+ [validate, current, props.value, props.onChange, allowBlank, blankText, validator, invalidText, minValue, maxValue, minText, maxText, nanText, decimalSeparator, allowDecimals, decimalPrecision],
597
+ );
598
+
599
+ // 已标红时值变化即时重校验(对齐 Ext 字段校验联动)。
600
+ useEffect(() => {
601
+ setError((prev) => (prev === null ? prev : computeNumberError(current, vProps)));
602
+ }, [current, allowBlank, blankText, validator, invalidText, minValue, maxValue, minText, maxText, nanText, decimalSeparator]);
603
+
604
+ // side 模式:感叹号图标定位到 input 右缘。
605
+ useLayoutEffect(() => {
606
+ if (error && msgTarget === 'side' && inputRef.current && iconRef.current) {
607
+ const inp = inputRef.current;
608
+ iconRef.current.style.left = `${inp.offsetLeft + inp.offsetWidth + 2}px`;
609
+ iconRef.current.style.top = `${inp.offsetTop}px`;
610
+ }
611
+ }, [error, msgTarget, current]);
612
+
613
+ // 自动 maskRe:baseChars + 小数分隔符 + '-'(若允许)。可被显式 maskRe 覆盖。
614
+ const autoMask = useMemo(() => {
615
+ if (maskRe) return maskRe;
616
+ let chars = baseChars;
617
+ if (allowDecimals) chars += decimalSeparator;
618
+ if (allowNegative) chars += '-';
619
+ const escaped = chars.replace(/[\\[\]^-]/g, '\\$&');
620
+ return new RegExp(`[${escaped}]`);
621
+ }, [maskRe, baseChars, allowDecimals, decimalSeparator, allowNegative]);
622
+
623
+ const inputClass = [
624
+ 'x-form-field',
625
+ 'x-form-num-field',
626
+ 'x-form-text',
627
+ props.cls,
628
+ focused ? 'x-form-focus' : '',
629
+ !focused && current.length === 0 && props.emptyText ? 'x-form-empty-field' : '',
630
+ error ? 'x-form-invalid' : '',
631
+ ]
632
+ .filter(Boolean)
633
+ .join(' ');
634
+
635
+ const handleFocus = useCallback(() => setFocused(true), []);
636
+ const handleBlur = useCallback(() => {
637
+ setFocused(false);
638
+ // 失焦时按精度修正显示值(对齐 Ext setValue 的 fixPrecision)。受控/非受控都要修正。
639
+ const fixed = fixPrecision(current, allowDecimals, decimalPrecision, decimalSeparator);
640
+ if (fixed !== current) {
641
+ if (props.value === undefined) setInternal(fixed);
642
+ else props.onChange?.(fixed);
643
+ }
644
+ if (validateOnBlur) validate();
645
+ }, [current, props.value, props.onChange, allowDecimals, decimalPrecision, decimalSeparator, validateOnBlur, validate]);
646
+
647
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
648
+ if (autoMask && e.key.length === 1 && !autoMask.test(e.key)) {
649
+ e.preventDefault();
650
+ }
651
+ };
652
+
653
+ const handleChange = useCallback(
654
+ (e: React.ChangeEvent<HTMLInputElement>) => {
655
+ let v = e.target.value;
656
+ // 录入时强制剔除不符合的数值字符(兜底粘贴场景)。
657
+ if (!allowNegative) v = v.replace(/-/g, '');
658
+ if (!allowDecimals) {
659
+ const idx = v.indexOf(decimalSeparator);
660
+ if (idx !== -1) v = v.slice(0, idx);
661
+ }
662
+ if (props.value === undefined) setInternal(v);
663
+ props.onChange?.(v);
664
+ },
665
+ [props.value, props.onChange, allowNegative, allowDecimals, decimalSeparator],
666
+ );
667
+
668
+ return (
669
+ <>
670
+ <input
671
+ ref={inputRef}
672
+ id={props.id}
673
+ name={props.name}
674
+ type="text"
675
+ inputMode="decimal"
676
+ className={inputClass}
677
+ value={current}
678
+ placeholder={props.emptyText}
679
+ disabled={props.disabled}
680
+ readOnly={props.readOnly}
681
+ style={{ height: 22, ...(props.width ? { width: props.width } : {}), ...props.style }}
682
+ onFocus={handleFocus}
683
+ onBlur={handleBlur}
684
+ onKeyDown={handleKeyDown}
685
+ onChange={handleChange}
686
+ />
687
+ {error && msgTarget === 'side' && (
688
+ <div
689
+ ref={iconRef}
690
+ className="x-form-invalid-icon"
691
+ title={error}
692
+ style={{ visibility: 'visible' }}
693
+ />
694
+ )}
695
+ {error && msgTarget === 'under' && (
696
+ <div className="x-form-invalid-msg" style={{ display: 'block' }}>
697
+ {error}
698
+ </div>
699
+ )}
700
+ </>
701
+ );
702
+ });
703
+
704
+ export { TextInput };
705
+
706
+ export interface TextAreaProps extends Omit<BaseInputProps, 'inputType'> {
707
+ /** 可见行数(默认 4)。 */
708
+ rows?: number;
709
+ /** 随内容自动增长高度(对齐 Ext grow,默认 false)。 */
710
+ grow?: boolean;
711
+ /** grow 时最小高度(默认 60)。 */
712
+ growMin?: number;
713
+ /** grow 时最大高度(默认 1000)。 */
714
+ growMax?: number;
715
+ /** true 时禁止滚动条(配合 grow 相当于 overflow:hidden,默认 false)。 */
716
+ preventScrollbars?: boolean;
717
+ /** 回车是否视为特殊键(Ext enterIsSpecial,默认 false;本复刻仅作为属性保留以对齐 API)。 */
718
+ enterIsSpecial?: boolean;
719
+ }
720
+ export const TextArea = forwardRef<TextFieldHandle, TextAreaProps>((props, ref) => {
721
+ const {
722
+ rows = 4,
723
+ grow = false,
724
+ growMin = 60,
725
+ growMax = 1000,
726
+ preventScrollbars = false,
727
+ allowBlank,
728
+ validator,
729
+ invalidText,
730
+ minLength,
731
+ maxLength,
732
+ minLengthText,
733
+ maxLengthText,
734
+ regex,
735
+ regexText,
736
+ vtype,
737
+ vtypeText,
738
+ maskRe,
739
+ stripCharsRe,
740
+ selectOnFocus = false,
741
+ msgTarget = 'side',
742
+ validateOnBlur = true,
743
+ } = props;
744
+ const [focused, setFocused] = useState(false);
745
+ const [internal, setInternal] = useFieldValue(props.value, props.defaultValue);
746
+ const [error, setError] = useState<string | null>(null);
747
+ const current = props.value !== undefined ? props.value : internal;
748
+ const taRef = useRef<HTMLTextAreaElement>(null);
749
+ const iconRef = useRef<HTMLDivElement>(null);
750
+ const initialRef = useRef<string>(props.value ?? props.defaultValue ?? '');
751
+
752
+ const vProps = {
753
+ allowBlank,
754
+ validator,
755
+ invalidText,
756
+ minLength,
757
+ maxLength,
758
+ minLengthText,
759
+ maxLengthText,
760
+ regex,
761
+ regexText,
762
+ vtype,
763
+ vtypeText,
764
+ };
765
+
766
+ const validate = useCallback((): boolean => {
767
+ const e = computeError(current, vProps);
768
+ setError(e);
769
+ return e === null;
770
+ }, [current, allowBlank, validator, invalidText, minLength, maxLength, minLengthText, maxLengthText, regex, regexText, vtype, vtypeText]);
771
+
772
+ useImperativeHandle(
773
+ ref,
774
+ () => ({
775
+ validate,
776
+ clearInvalid: () => setError(null),
777
+ isValid: () => computeError(current, vProps) === null,
778
+ reset: () => {
779
+ setInternal(initialRef.current);
780
+ setError(null);
781
+ },
782
+ setValue: (v: string) => {
783
+ if (props.value === undefined) setInternal(v);
784
+ props.onChange?.(v);
785
+ },
786
+ getValue: () => current,
787
+ selectText: (start = 0, end = current.length) => {
788
+ const el = taRef.current;
789
+ if (!el) return;
790
+ el.focus();
791
+ try {
792
+ el.setSelectionRange(start, end);
793
+ } catch {
794
+ /* IE 分支省略 */
795
+ }
796
+ },
797
+ focus: () => taRef.current?.focus(),
798
+ }),
799
+ [validate, current, props.value, props.onChange, allowBlank, validator, invalidText, minLength, maxLength, minLengthText, maxLengthText, regex, regexText, vtype, vtypeText],
800
+ );
801
+
802
+ const inputClass = [
803
+ 'x-form-field',
804
+ 'x-form-text',
805
+ 'x-form-textarea',
806
+ props.cls,
807
+ focused ? 'x-form-focus' : '',
808
+ !focused && current.length === 0 && props.emptyText ? 'x-form-empty-field' : '',
809
+ error ? 'x-form-invalid' : '',
810
+ ]
811
+ .filter(Boolean)
812
+ .join(' ');
813
+
814
+ // 已标红时,值变化即时重校验(对齐 Ext 字段校验联动行为)。
815
+ useEffect(() => {
816
+ setError((prev) => (prev === null ? prev : computeError(current, vProps)));
817
+ }, [current, allowBlank, validator, invalidText, minLength, maxLength, minLengthText, maxLengthText, regex, regexText, vtype, vtypeText]);
818
+
819
+ // grow:随内容自动调整高度,范围为 [growMin, growMax]。
820
+ useLayoutEffect(() => {
821
+ if (!grow || !taRef.current) return;
822
+ const el = taRef.current;
823
+ el.style.height = 'auto';
824
+ const h = Math.min(Math.max(el.scrollHeight, growMin), growMax);
825
+ el.style.height = `${h}px`;
826
+ }, [grow, growMin, growMax, current, focused]);
827
+
828
+ // side 模式:感叹号图标定位到 textarea 右缘。
829
+ useLayoutEffect(() => {
830
+ if (error && msgTarget === 'side' && taRef.current && iconRef.current) {
831
+ const el = taRef.current;
832
+ iconRef.current.style.left = `${el.offsetLeft + el.offsetWidth + 2}px`;
833
+ iconRef.current.style.top = `${el.offsetTop}px`;
834
+ }
835
+ }, [error, msgTarget, current]);
836
+
837
+ const handleFocus = useCallback(() => {
838
+ setFocused(true);
839
+ if (selectOnFocus && taRef.current) {
840
+ const el = taRef.current;
841
+ el.focus();
842
+ try {
843
+ el.setSelectionRange(0, el.value.length);
844
+ } catch {
845
+ /* IE 分支省略 */
846
+ }
847
+ }
848
+ }, [selectOnFocus]);
849
+
850
+ const handleBlur = useCallback(() => {
851
+ setFocused(false);
852
+ if (validateOnBlur) validate();
853
+ }, [validateOnBlur, validate]);
854
+
855
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
856
+ if (maskRe && e.key.length === 1 && !maskRe.test(e.key)) {
857
+ e.preventDefault();
858
+ }
859
+ };
860
+
861
+ const handleChange = useCallback(
862
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
863
+ let v = e.target.value;
864
+ if (stripCharsRe) v = v.replace(stripCharsRe, '');
865
+ if (props.value === undefined) setInternal(v);
866
+ props.onChange?.(v);
867
+ },
868
+ [props.value, props.onChange, stripCharsRe],
869
+ );
870
+
871
+ return (
872
+ <>
873
+ <textarea
874
+ ref={taRef}
875
+ id={props.id}
876
+ name={props.name}
877
+ className={inputClass}
878
+ rows={rows}
879
+ value={current}
880
+ placeholder={props.emptyText}
881
+ disabled={props.disabled}
882
+ readOnly={props.readOnly}
883
+ style={{
884
+ ...(grow ? { height: growMin } : { height: 60 }),
885
+ ...(props.width ? { width: props.width } : {}),
886
+ ...(preventScrollbars ? { overflow: 'hidden' } : {}),
887
+ }}
888
+ onFocus={handleFocus}
889
+ onBlur={handleBlur}
890
+ onKeyDown={handleKeyDown}
891
+ onChange={handleChange}
892
+ />
893
+ {error && msgTarget === 'side' && (
894
+ <div
895
+ ref={iconRef}
896
+ className="x-form-invalid-icon"
897
+ title={error}
898
+ style={{ visibility: 'visible' }}
899
+ />
900
+ )}
901
+ {error && msgTarget === 'under' && (
902
+ <div className="x-form-invalid-msg" style={{ display: 'block' }}>
903
+ {error}
904
+ </div>
905
+ )}
906
+ </>
907
+ );
908
+ });
909
+
910
+ /* ------------------------------------------------------------------ */
911
+ /* Checkbox —— 复刻 Checkbox.onRender 的 x-form-check-wrap */
912
+ /* ------------------------------------------------------------------ */
913
+ /** 命令式句柄(对齐 Ext.form.Checkbox 的 getValue / setValue / toggle / focus)。 */
914
+ export interface CheckboxHandle {
915
+ /** 获取勾选态(对齐 Ext Checkbox.getValue,返回 boolean)。 */
916
+ getValue: () => boolean;
917
+ /** 设置勾选态:传 boolean 直接设定;传 inputValue 字符串时匹配即选(对齐 Ext setValue 接受原始值)。 */
918
+ setValue: (v: boolean | string) => void;
919
+ /** 切换勾选态(对齐 Ext Checkbox.toggle)。 */
920
+ toggle: () => void;
921
+ /** 聚焦选项框。 */
922
+ focus: () => void;
923
+ }
924
+
925
+ export interface CheckboxProps {
926
+ id?: string;
927
+ name?: string;
928
+ /** 选框右侧文字(对齐 Ext boxLabel),与 htmlFor 关联点击切换。 */
929
+ boxLabel?: ReactNode;
930
+ /** 提交时的值(对齐 Ext inputValue,默认 'on');仅勾选时随表单提交该值。 */
931
+ inputValue?: string;
932
+ /** 受控勾选态。提供时作为唯一来源(受控模式)。 */
933
+ checked?: boolean;
934
+ /** 非受控初始勾选态(默认 false)。 */
935
+ defaultChecked?: boolean;
936
+ /** 禁用(对齐 Ext disabled,整组 x-item-disabled 置灰)。 */
937
+ disabled?: boolean;
938
+ /** 勾选态变化回调,返回当前是否勾选。 */
939
+ onChange?: (checked: boolean) => void;
940
+ /** 附加 class(追加到 x-form-check-wrap 之后)。 */
941
+ cls?: string;
942
+ /** 根 span 行内样式。 */
943
+ style?: CSSProperties;
944
+ }
945
+
946
+ export const Checkbox = forwardRef<CheckboxHandle, CheckboxProps>(function Checkbox(
947
+ {
948
+ id,
949
+ name,
950
+ boxLabel,
951
+ inputValue = 'on',
952
+ checked,
953
+ defaultChecked,
954
+ disabled,
955
+ onChange,
956
+ cls = '',
957
+ style,
958
+ },
959
+ ref,
960
+ ) {
961
+ const [internal, setInternal] = useState(checked ?? defaultChecked ?? false);
962
+ const isChecked = checked !== undefined ? checked : internal;
963
+ const inputRef = useRef<HTMLInputElement>(null);
964
+
965
+ const setChecked = useCallback(
966
+ (v: boolean) => {
967
+ if (checked === undefined) setInternal(v);
968
+ onChange?.(v);
969
+ },
970
+ [checked, onChange],
971
+ );
972
+
973
+ useImperativeHandle(
974
+ ref,
975
+ () => ({
976
+ getValue: () => (checked !== undefined ? checked : internal),
977
+ setValue: (v: boolean | string) => setChecked(v === true || v === inputValue),
978
+ toggle: () =>
979
+ setChecked(!(checked !== undefined ? checked : internal)),
980
+ focus: () => inputRef.current?.focus(),
981
+ }),
982
+ [checked, internal, inputValue, setChecked],
983
+ );
984
+
985
+ const wrapClass = ['x-form-check-wrap', disabled ? 'x-item-disabled' : '', cls]
986
+ .filter(Boolean)
987
+ .join(' ');
988
+
989
+ return (
990
+ <span className={wrapClass} style={style}>
991
+ <input
992
+ ref={inputRef}
993
+ id={id}
994
+ name={name}
995
+ type="checkbox"
996
+ className="x-form-field x-form-checkbox"
997
+ value={inputValue}
998
+ checked={isChecked}
999
+ disabled={disabled}
1000
+ onChange={(e) => setChecked(e.target.checked)}
1001
+ />
1002
+ {boxLabel != null && (
1003
+ <label htmlFor={id} className="x-form-cb-label">
1004
+ {boxLabel}
1005
+ </label>
1006
+ )}
1007
+ </span>
1008
+ );
1009
+ });
1010
+ Checkbox.displayName = 'Checkbox';
1011
+
1012
+ /* ------------------------------------------------------------------ */
1013
+ /* Radio —— 复刻 Radio.js(继承 Checkbox,inputType=radio) */
1014
+ /* onRender:x-form-check-wrap > input.x-form-field.x-form-radio */
1015
+ /* + label.x-form-cb-label(boxLabel) */
1016
+ /* ------------------------------------------------------------------ */
1017
+ export interface RadioProps {
1018
+ id?: string;
1019
+ name?: string;
1020
+ boxLabel?: ReactNode;
1021
+ inputValue?: string;
1022
+ checked?: boolean;
1023
+ defaultChecked?: boolean;
1024
+ disabled?: boolean;
1025
+ cls?: string;
1026
+ style?: CSSProperties;
1027
+ onChange?: (checked: boolean) => void;
1028
+ }
1029
+ export interface RadioHandle {
1030
+ getValue: () => boolean;
1031
+ setValue: (v: boolean | string) => void;
1032
+ toggle: () => void;
1033
+ focus: () => void;
1034
+ }
1035
+ export const Radio = forwardRef<RadioHandle, RadioProps>(function Radio(
1036
+ {
1037
+ id,
1038
+ name,
1039
+ boxLabel,
1040
+ inputValue,
1041
+ checked,
1042
+ defaultChecked,
1043
+ disabled,
1044
+ cls,
1045
+ style,
1046
+ onChange,
1047
+ },
1048
+ ref
1049
+ ) {
1050
+ const [internal, setInternal] = useState(checked ?? defaultChecked ?? false);
1051
+ const isChecked = checked !== undefined ? checked : internal;
1052
+
1053
+ const inputRef = useRef<HTMLInputElement>(null);
1054
+ // 跟踪最新勾选态,供命令式句柄读取(对齐 Ext getValue)。
1055
+ const checkedRef = useRef(isChecked);
1056
+ checkedRef.current = isChecked;
1057
+
1058
+ const apply = (v: boolean) => {
1059
+ if (checked === undefined) setInternal(v);
1060
+ onChange?.(v);
1061
+ };
1062
+
1063
+ useImperativeHandle(ref, () => ({
1064
+ getValue: () => checkedRef.current,
1065
+ setValue: (v) => {
1066
+ if (typeof v === 'string') {
1067
+ const on = v === inputValue;
1068
+ apply(on);
1069
+ } else {
1070
+ apply(v);
1071
+ }
1072
+ },
1073
+ toggle: () => apply(!checkedRef.current),
1074
+ focus: () => inputRef.current?.focus(),
1075
+ }));
1076
+
1077
+ return (
1078
+ <span
1079
+ className={['x-form-check-wrap', disabled ? 'x-item-disabled' : '', cls]
1080
+ .filter(Boolean)
1081
+ .join(' ')}
1082
+ style={style}
1083
+ >
1084
+ <input
1085
+ ref={inputRef}
1086
+ id={id}
1087
+ name={name}
1088
+ type="radio"
1089
+ className="x-form-field x-form-radio"
1090
+ value={inputValue}
1091
+ checked={isChecked}
1092
+ disabled={disabled}
1093
+ onChange={(e) => {
1094
+ const v = e.target.checked;
1095
+ if (checked === undefined) setInternal(v);
1096
+ // 受控(如 RadioGroup)场景下,onChange 已由上层替换为携带 inputValue 的回调,
1097
+ // 这里统一回调即可触发组协调(互斥取消其他项)。
1098
+ onChange?.(v);
1099
+ }}
1100
+ />
1101
+ {boxLabel != null && (
1102
+ <label htmlFor={id} className="x-form-cb-label">
1103
+ {boxLabel}
1104
+ </label>
1105
+ )}
1106
+ </span>
1107
+ );
1108
+ });
1109
+ Radio.displayName = 'Radio';
1110
+
1111
+ export interface RadioGroupProps {
1112
+ name?: string;
1113
+ value?: string;
1114
+ defaultValue?: string;
1115
+ fieldLabel?: ReactNode;
1116
+ labelAlign?: 'left' | 'top' | 'right';
1117
+ labelWidth?: number;
1118
+ labelPad?: number;
1119
+ labelSeparator?: string;
1120
+ hideLabel?: boolean;
1121
+ /** 列数(对齐 Ext RadioGroup.columns):>1 时按多列横向排布,默认 1(竖向堆叠) */
1122
+ columns?: number;
1123
+ /** 是否竖向堆叠(对齐 Ext RadioGroup.vertical);默认 false(columns=1 时仍竖向,columns>1 时不生效) */
1124
+ vertical?: boolean;
1125
+ /** 组内内容区宽度(对齐 Ext field width);不传则按内容自适应,与其它表单字段对齐时显式传入 */
1126
+ width?: number;
1127
+ style?: React.CSSProperties;
1128
+ className?: string;
1129
+ children?: ReactNode;
1130
+ onChange?: (value: string) => void;
1131
+ }
1132
+ export interface RadioGroupHandle {
1133
+ /** 获取当前选中项的 inputValue(对齐 Ext RadioGroup.getValue) */
1134
+ getValue: () => string | undefined;
1135
+ /** 程序化选中某项(对齐 Ext RadioGroup.setValue) */
1136
+ setValue: (v: string) => void;
1137
+ }
1138
+ /**
1139
+ * RadioGroup —— 复刻 Ext.form.RadioGroup 的包裹容器 x-form-radio-group。
1140
+ * 通过受控组 value 协调组内单选(同名 + 互斥),组内 Radio 用相同 name 由浏览器原生分组。
1141
+ * 支持 columns / vertical 布局(对齐 Ext.columns / vertical),并暴露 getValue / setValue 命令句柄。
1142
+ */
1143
+ export const RadioGroup = forwardRef<RadioGroupHandle, RadioGroupProps>(function RadioGroup(
1144
+ {
1145
+ name,
1146
+ value,
1147
+ defaultValue,
1148
+ fieldLabel,
1149
+ labelAlign = 'left',
1150
+ labelWidth = 100,
1151
+ labelPad = 5,
1152
+ labelSeparator = ':',
1153
+ hideLabel,
1154
+ columns,
1155
+ vertical,
1156
+ width,
1157
+ style,
1158
+ className,
1159
+ children,
1160
+ onChange,
1161
+ },
1162
+ ref
1163
+ ) {
1164
+ // 初值优先级:受控 value > 组 defaultValue > 子 Radio 的 defaultChecked 推导。
1165
+ const [internal, setInternal] = useState<string | undefined>(() => {
1166
+ if (value !== undefined) return value;
1167
+ if (defaultValue !== undefined) return defaultValue;
1168
+ let initial: string | undefined;
1169
+ Children.forEach(children, (child) => {
1170
+ if (!isValidElement(child)) return;
1171
+ const c = child as ReactElement<{ defaultChecked?: boolean; inputValue?: string }>;
1172
+ if (c.props.defaultChecked) initial = c.props.inputValue;
1173
+ });
1174
+ return initial;
1175
+ });
1176
+ const current = value !== undefined ? value : internal;
1177
+ const currentRef = useRef(current);
1178
+ currentRef.current = current;
1179
+
1180
+ useImperativeHandle(ref, () => ({
1181
+ getValue: () => currentRef.current,
1182
+ setValue: (v) => {
1183
+ if (value === undefined) setInternal(v);
1184
+ onChange?.(v);
1185
+ },
1186
+ }));
1187
+
1188
+ const items = Children.map(children, (child) => {
1189
+ if (!isValidElement(child)) return child;
1190
+ const c = child as ReactElement<{ name?: string; inputValue?: string; checked?: boolean; onChange?: (v: boolean) => void }>;
1191
+ const childName = c.props.name ?? name;
1192
+ const childValue = c.props.inputValue;
1193
+ return cloneElement(c, {
1194
+ name: childName,
1195
+ checked: current === childValue,
1196
+ onChange: () => {
1197
+ if (value === undefined) setInternal(childValue);
1198
+ onChange?.(childValue as string);
1199
+ },
1200
+ } as Partial<typeof c.props>);
1201
+ });
1202
+
1203
+ // 布局对齐 Ext:RadioGroup 默认横向单行(vertical 默认 false);
1204
+ // vertical=true 才竖向堆叠;columns>1 时横向多列网格(忽略 vertical)。
1205
+ const cols = typeof columns === 'number' && columns > 1 ? columns : 1;
1206
+ const horizontal = !vertical;
1207
+ const groupStyle: CSSProperties = {
1208
+ ...(horizontal
1209
+ ? { display: 'flex', flexWrap: cols > 1 ? 'wrap' : 'nowrap', gap: 8, alignItems: 'center' }
1210
+ : { display: 'flex', flexDirection: 'column', gap: 6 }),
1211
+ ...(width != null ? { width } : {}),
1212
+ ...style,
1213
+ };
1214
+
1215
+ const isTop = labelAlign === 'top';
1216
+ const labelStyle: CSSProperties = isTop ? { width: 'auto' } : { width: `${labelWidth}px` };
1217
+ const itemClass = ['x-form-item', `x-form-label-${labelAlign}`]
1218
+ .filter(Boolean)
1219
+ .join(' ');
1220
+ return (
1221
+ <div className={[itemClass, className].filter(Boolean).join(' ')} tabIndex={-1}>
1222
+ {!hideLabel && fieldLabel != null && (
1223
+ <label style={labelStyle} className="x-form-item-label">
1224
+ {fieldLabel}
1225
+ {labelSeparator}
1226
+ </label>
1227
+ )}
1228
+ <div
1229
+ className="x-form-element"
1230
+ style={isTop ? { paddingLeft: 0 } : { paddingLeft: `${labelWidth + labelPad}px` }}
1231
+ >
1232
+ <div className="x-form-radio-group" style={groupStyle}>
1233
+ {cols > 1
1234
+ ? items?.map((it, i) => (
1235
+ <div key={i} style={{ width: `calc(${100 / cols}% - 8px)` }}>
1236
+ {it}
1237
+ </div>
1238
+ ))
1239
+ : items}
1240
+ </div>
1241
+ </div>
1242
+ <div className="x-form-clear-left"></div>
1243
+ </div>
1244
+ );
1245
+ });
1246
+ RadioGroup.displayName = 'RadioGroup';
1247
+
1248
+ /* ------------------------------------------------------------------ */
1249
+ /* Hidden —— 复刻 Ext.form.Hidden(input type=hidden,无可见 UI) */
1250
+ /* ------------------------------------------------------------------ */
1251
+ export interface HiddenProps {
1252
+ name?: string;
1253
+ value?: string;
1254
+ }
1255
+ /** 命令式句柄:对齐 Ext.form.Hidden 的 getValue / setValue */
1256
+ export interface HiddenHandle {
1257
+ getValue: () => string;
1258
+ setValue: (v: string) => void;
1259
+ }
1260
+ export const Hidden = forwardRef<HiddenHandle, HiddenProps>(
1261
+ ({ name, value }, ref) => {
1262
+ const inputRef = useRef<HTMLInputElement>(null);
1263
+ const [internal, setInternal] = useState(value ?? '');
1264
+ const current = value !== undefined ? value : internal;
1265
+
1266
+ useImperativeHandle(ref, () => ({
1267
+ getValue: () => (value !== undefined ? value : (inputRef.current?.value ?? internal)),
1268
+ setValue: (v: string) => {
1269
+ if (inputRef.current) inputRef.current.value = v;
1270
+ if (value === undefined) setInternal(v);
1271
+ },
1272
+ }));
1273
+
1274
+ return <input ref={inputRef} type="hidden" name={name} className="x-hidden" value={current} />;
1275
+ },
1276
+ );
1277
+ Hidden.displayName = 'Hidden';
1278
+
1279
+ /* ------------------------------------------------------------------ */
1280
+ /* TriggerField —— 复刻 Ext.form.TriggerField(文本 + 触发器基类) */
1281
+ /* onRender:span.x-form-field-wrap.x-form-field-trigger-wrap */
1282
+ /* > input.x-form-text + img.x-form-trigger(可加 triggerClass)*/
1283
+ /* 点击触发器触发 onTriggerClick(对齐 Ext onTriggerClick);本身不含 */
1284
+ /* 下拉,下拉类(ComboBox/DateField)在其上扩展。 */
1285
+ /* ------------------------------------------------------------------ */
1286
+ export interface TriggerFieldProps {
1287
+ id?: string;
1288
+ name?: string;
1289
+ value?: string;
1290
+ width?: number;
1291
+ editable?: boolean;
1292
+ disabled?: boolean;
1293
+ /** 附加在触发器 img 上的 class(对齐 Ext triggerClass),用于自定义图标 */
1294
+ triggerClass?: string;
1295
+ /** 隐藏触发器(对齐 Ext hideTrigger) */
1296
+ hideTrigger?: boolean;
1297
+ emptyText?: string;
1298
+ placeholder?: string;
1299
+ /** 点击触发器回调(对齐 Ext onTriggerClick) */
1300
+ onTriggerClick?: () => void;
1301
+ onChange?: (value: string) => void;
1302
+ cls?: string;
1303
+ style?: React.CSSProperties;
1304
+ }
1305
+ /** 命令式句柄:对齐 Ext.form.TriggerField 的 getValue / setValue / focus */
1306
+ export interface TriggerFieldHandle {
1307
+ getValue: () => string;
1308
+ setValue: (v: string) => void;
1309
+ focus: () => void;
1310
+ }
1311
+ export const TriggerField = forwardRef<TriggerFieldHandle, TriggerFieldProps>(
1312
+ (
1313
+ {
1314
+ id,
1315
+ name,
1316
+ value,
1317
+ width,
1318
+ editable = true,
1319
+ disabled,
1320
+ triggerClass,
1321
+ hideTrigger,
1322
+ emptyText,
1323
+ placeholder,
1324
+ onTriggerClick,
1325
+ onChange,
1326
+ cls,
1327
+ style,
1328
+ },
1329
+ ref,
1330
+ ) => {
1331
+ const [internal, setInternal] = useState(value ?? '');
1332
+ const current = value !== undefined ? value : internal;
1333
+ const [focused, setFocused] = useState(false);
1334
+ const [triggerOver, setTriggerOver] = useState(false);
1335
+ const inputRef = useRef<HTMLInputElement>(null);
1336
+
1337
+ useImperativeHandle(ref, () => ({
1338
+ getValue: () => (value !== undefined ? value : (inputRef.current?.value ?? internal)),
1339
+ setValue: (v: string) => {
1340
+ if (inputRef.current) inputRef.current.value = v;
1341
+ if (value === undefined) setInternal(v);
1342
+ onChange?.(v);
1343
+ },
1344
+ focus: () => inputRef.current?.focus(),
1345
+ }));
1346
+
1347
+ const wrapClass = [
1348
+ 'x-form-field-wrap',
1349
+ 'x-form-field-trigger-wrap',
1350
+ focused ? 'x-trigger-wrap-focus' : '',
1351
+ cls ?? '',
1352
+ ]
1353
+ .filter(Boolean)
1354
+ .join(' ');
1355
+
1356
+ const TRIGGER_W = 17;
1357
+
1358
+ return (
1359
+ <span style={{ position: 'relative', display: 'inline-block', width: width ?? undefined, ...style }}>
1360
+ <span
1361
+ className={wrapClass}
1362
+ style={{ display: 'block', width: '100%', position: 'relative' }}
1363
+ >
1364
+ <input
1365
+ ref={inputRef}
1366
+ id={id}
1367
+ name={name}
1368
+ type="text"
1369
+ className="x-form-field x-form-text"
1370
+ value={current}
1371
+ readOnly={!editable}
1372
+ disabled={disabled}
1373
+ placeholder={placeholder ?? emptyText}
1374
+ style={{
1375
+ height: 22,
1376
+ width: hideTrigger ? '100%' : `calc(100% - ${TRIGGER_W}px)`,
1377
+ boxSizing: 'border-box',
1378
+ }}
1379
+ onFocus={() => setFocused(true)}
1380
+ onBlur={() => setFocused(false)}
1381
+ onChange={(e) => {
1382
+ if (value === undefined) setInternal(e.target.value);
1383
+ onChange?.(e.target.value);
1384
+ }}
1385
+ />
1386
+ {!hideTrigger && (
1387
+ <img
1388
+ className={[
1389
+ 'x-form-trigger',
1390
+ triggerClass ?? '',
1391
+ triggerOver ? 'x-form-trigger-over' : '',
1392
+ ]
1393
+ .filter(Boolean)
1394
+ .join(' ')}
1395
+ src={BLANK_IMG}
1396
+ alt=""
1397
+ role="button"
1398
+ style={{ position: 'absolute', right: 0, top: 0, width: TRIGGER_W, height: 22 }}
1399
+ onMouseEnter={() => setTriggerOver(true)}
1400
+ onMouseLeave={() => setTriggerOver(false)}
1401
+ onClick={() => !disabled && onTriggerClick?.()}
1402
+ />
1403
+ )}
1404
+ </span>
1405
+ </span>
1406
+ );
1407
+ },
1408
+ );
1409
+ TriggerField.displayName = 'TriggerField';
1410
+
1411
+ /* ------------------------------------------------------------------ */
1412
+ /* SearchField —— 复刻 Ext.ux.form.SearchField(TwinTriggerField 子类) */
1413
+ /* onRender:span.x-form-field-wrap.x-form-field-trigger-wrap */
1414
+ /* > input.x-form-text + span.x-form-twin-triggers */
1415
+ /* > img.x-form-trigger.x-form-clear-trigger(默认隐藏) */
1416
+ /* > img.x-form-trigger.x-form-search-trigger */
1417
+ /* 行为对齐 Ext:Enter / 点搜索触发 onTrigger2Click;清除键在搜索后显示, */
1418
+ /* 点清除触发 onTrigger1Click(清空值、隐藏清除键)。store 的 reload 在 */
1419
+ /* React 中以 onSearch / onClear 回调取代。 */
1420
+ /* ------------------------------------------------------------------ */
1421
+ export interface SearchFieldProps {
1422
+ id?: string;
1423
+ name?: string;
1424
+ value?: string;
1425
+ /** 字段宽度(对齐 Ext width,默认 180) */
1426
+ width?: number;
1427
+ disabled?: boolean;
1428
+ /** 只读(不可编辑,对齐 Ext editable:false) */
1429
+ editable?: boolean;
1430
+ emptyText?: string;
1431
+ placeholder?: string;
1432
+ /** 参数名(对齐 Ext paramName,仅作语义保留) */
1433
+ paramName?: string;
1434
+ /** 点击搜索触发(对齐 Ext onTrigger2Click 的 store.reload 行为) */
1435
+ onSearch?: (query: string) => void;
1436
+ /** 点击清除触发(对齐 Ext onTrigger1Click) */
1437
+ onClear?: () => void;
1438
+ onChange?: (value: string) => void;
1439
+ cls?: string;
1440
+ style?: CSSProperties;
1441
+ }
1442
+ /** 命令式句柄:对齐 TriggerField.getValue / setValue / focus,外加 clear/reset */
1443
+ export interface SearchFieldHandle {
1444
+ getValue: () => string;
1445
+ setValue: (v: string) => void;
1446
+ reset: () => void;
1447
+ clear: () => void;
1448
+ focus: () => void;
1449
+ }
1450
+ export const SearchField = forwardRef<SearchFieldHandle, SearchFieldProps>(
1451
+ (
1452
+ {
1453
+ id,
1454
+ name,
1455
+ value,
1456
+ width = 180,
1457
+ disabled,
1458
+ editable = true,
1459
+ emptyText,
1460
+ placeholder,
1461
+ paramName = 'query',
1462
+ onSearch,
1463
+ onClear,
1464
+ onChange,
1465
+ cls,
1466
+ style,
1467
+ },
1468
+ ref,
1469
+ ) => {
1470
+ const isControlled = value !== undefined;
1471
+ const [internal, setInternal] = useState(value ?? '');
1472
+ const current = isControlled ? value! : internal;
1473
+ const [focused, setFocused] = useState(false);
1474
+ const [hasSearch, setHasSearch] = useState(false);
1475
+ const [over1, setOver1] = useState(false); // 清除 trigger
1476
+ const [over2, setOver2] = useState(false); // 搜索 trigger
1477
+ const inputRef = useRef<HTMLInputElement>(null);
1478
+
1479
+ const setRaw = (v: string) => {
1480
+ if (!isControlled) setInternal(v);
1481
+ onChange?.(v);
1482
+ };
1483
+
1484
+ // 对齐 Ext onTrigger2Click(搜索)
1485
+ const onSearchClick = () => {
1486
+ if (current.length < 1) {
1487
+ onClearClick();
1488
+ return;
1489
+ }
1490
+ onSearch?.(current);
1491
+ setHasSearch(true);
1492
+ };
1493
+
1494
+ // 对齐 Ext onTrigger1Click(清除)
1495
+ const onClearClick = () => {
1496
+ if (!hasSearch) return;
1497
+ if (inputRef.current) inputRef.current.value = '';
1498
+ setRaw('');
1499
+ onClear?.();
1500
+ setHasSearch(false);
1501
+ };
1502
+
1503
+ useImperativeHandle(ref, () => ({
1504
+ getValue: () => (isControlled ? (inputRef.current?.value ?? current) : (inputRef.current?.value ?? internal)),
1505
+ setValue: (v: string) => {
1506
+ if (inputRef.current) inputRef.current.value = v;
1507
+ setRaw(v);
1508
+ },
1509
+ reset: () => {
1510
+ if (inputRef.current) inputRef.current.value = '';
1511
+ setRaw('');
1512
+ setHasSearch(false);
1513
+ onClear?.();
1514
+ },
1515
+ clear: () => {
1516
+ if (inputRef.current) inputRef.current.value = '';
1517
+ setRaw('');
1518
+ setHasSearch(false);
1519
+ onClear?.();
1520
+ },
1521
+ focus: () => inputRef.current?.focus(),
1522
+ }));
1523
+
1524
+ const wrapClass = [
1525
+ 'x-form-field-wrap',
1526
+ 'x-form-field-trigger-wrap',
1527
+ 'x-form-twin-triggers-wrap',
1528
+ focused ? 'x-trigger-wrap-focus' : '',
1529
+ cls ?? '',
1530
+ ]
1531
+ .filter(Boolean)
1532
+ .join(' ');
1533
+
1534
+ return (
1535
+ <span style={{ position: 'relative', display: 'inline-block', width, ...style }}>
1536
+ <span
1537
+ className={wrapClass}
1538
+ style={{ display: 'flex', alignItems: 'stretch', width: '100%', position: 'relative' }}
1539
+ >
1540
+ <input
1541
+ ref={inputRef}
1542
+ id={id}
1543
+ name={name}
1544
+ data-param-name={paramName}
1545
+ type="text"
1546
+ className="x-form-field x-form-text"
1547
+ value={current}
1548
+ readOnly={!editable}
1549
+ disabled={disabled}
1550
+ placeholder={placeholder ?? emptyText}
1551
+ style={{
1552
+ flex: '1 1 auto',
1553
+ minWidth: 0,
1554
+ height: 22,
1555
+ boxSizing: 'border-box',
1556
+ }}
1557
+ onFocus={() => setFocused(true)}
1558
+ onBlur={() => setFocused(false)}
1559
+ onKeyDown={(e) => {
1560
+ if (e.key === 'Enter') {
1561
+ e.preventDefault();
1562
+ onSearchClick();
1563
+ }
1564
+ }}
1565
+ onChange={(e) => setRaw(e.target.value)}
1566
+ />
1567
+ <span className="x-form-twin-triggers" style={{ display: 'flex', flex: '0 0 auto' }}>
1568
+ <img
1569
+ className={['x-form-trigger', 'x-form-clear-trigger', over1 ? 'x-form-trigger-over' : ''].filter(Boolean).join(' ')}
1570
+ src={BLANK_IMG}
1571
+ alt=""
1572
+ role="button"
1573
+ style={hasSearch ? { flex: '0 0 auto' } : { display: 'none' }}
1574
+ onMouseEnter={() => setOver1(true)}
1575
+ onMouseLeave={() => setOver1(false)}
1576
+ onClick={() => !disabled && onClearClick()}
1577
+ />
1578
+ <img
1579
+ className={['x-form-trigger', 'x-form-search-trigger', over2 ? 'x-form-trigger-over' : ''].filter(Boolean).join(' ')}
1580
+ src={BLANK_IMG}
1581
+ alt=""
1582
+ role="button"
1583
+ style={{ flex: '0 0 auto' }}
1584
+ onMouseEnter={() => setOver2(true)}
1585
+ onMouseLeave={() => setOver2(false)}
1586
+ onClick={() => !disabled && onSearchClick()}
1587
+ />
1588
+ </span>
1589
+ </span>
1590
+ </span>
1591
+ );
1592
+ },
1593
+ );
1594
+ SearchField.displayName = 'SearchField';
1595
+
1596
+ /* ------------------------------------------------------------------ */
1597
+ /* FileUploadField —— 复刻 Ext.ux.form.FileUploadField(TextField + 文件按钮)*/
1598
+ /* onRender:span.x-form-field-wrap.x-form-file-wrap */
1599
+ /* > input.x-form-text.x-form-file-text(只读,显示文件名,z-index:3) */
1600
+ /* > span.x-form-file-btn(绝对定位右侧,z-index:1) */
1601
+ /* > Ext.Button("Browse...") */
1602
+ /* > input.x-form-file[type=file](透明覆盖按钮,z-index:2,触发系统对话框)*/
1603
+ /* buttonOnly 隐藏文本框、wrap 收为按钮宽;fileselected 事件 -> onFileSelected。*/
1604
+ /* ------------------------------------------------------------------ */
1605
+ export interface FileUploadFieldProps {
1606
+ id?: string;
1607
+ name?: string;
1608
+ /** 受控显示值(文件名)。不提供时由内部状态管理(选中文件后写入文件名)。 */
1609
+ value?: string;
1610
+ width?: number;
1611
+ disabled?: boolean;
1612
+ /** 按钮文字(对齐 Ext buttonText,默认 'Browse...') */
1613
+ buttonText?: string;
1614
+ /** 仅显示按钮、隐藏文本框(对齐 Ext buttonOnly,默认 false) */
1615
+ buttonOnly?: boolean;
1616
+ /** 按钮与文本框间距像素(对齐 Ext buttonOffset,默认 3) */
1617
+ buttonOffset?: number;
1618
+ /** 对齐 Ext buttonCfg:可覆盖按钮文字 / 图标 class / 附加 class */
1619
+ buttonCfg?: { text?: string; iconCls?: string; cls?: string };
1620
+ /** 占位提示(未选择文件时) */
1621
+ emptyText?: string;
1622
+ /** file input 的 accept 过滤(HTML 原生,扩展项) */
1623
+ accept?: string;
1624
+ /** 是否允许多选(HTML 原生,扩展项) */
1625
+ multiple?: boolean;
1626
+ /** 内容变化回调(文件名) */
1627
+ onChange?: (value: string) => void;
1628
+ /** 对齐 Ext fileselected 事件:选中文件后回调(value, file) */
1629
+ onFileSelected?: (value: string, file?: File) => void;
1630
+ cls?: string;
1631
+ style?: React.CSSProperties;
1632
+ }
1633
+ /** 命令式句柄:对齐 Ext.ux.form.FileUploadField 的 getValue / setValue 与文件读取 */
1634
+ export interface FileUploadFieldHandle {
1635
+ getValue: () => string;
1636
+ setValue: (v: string) => void;
1637
+ getFile: () => File | null;
1638
+ reset: () => void;
1639
+ focus: () => void;
1640
+ }
1641
+ export const FileUploadField = forwardRef<FileUploadFieldHandle, FileUploadFieldProps>(
1642
+ (
1643
+ {
1644
+ id,
1645
+ name,
1646
+ value,
1647
+ width,
1648
+ disabled,
1649
+ buttonText = 'Browse...',
1650
+ buttonOnly = false,
1651
+ buttonOffset = 8,
1652
+ buttonCfg,
1653
+ emptyText,
1654
+ accept,
1655
+ multiple,
1656
+ onChange,
1657
+ onFileSelected,
1658
+ cls,
1659
+ style,
1660
+ },
1661
+ ref,
1662
+ ) => {
1663
+ const wrapW = width ?? 220;
1664
+ const btnText = buttonCfg?.text ?? buttonText;
1665
+ const [internal, setInternal] = useState(value ?? '');
1666
+ const current = value !== undefined ? value : internal;
1667
+ const [file, setFile] = useState<File | null>(null);
1668
+ const [focused, setFocused] = useState(false);
1669
+ const [btnW, setBtnW] = useState(0);
1670
+ const [resetKey, setResetKey] = useState(0);
1671
+ const btnRef = useRef<HTMLSpanElement>(null);
1672
+ const textRef = useRef<HTMLInputElement>(null);
1673
+
1674
+ useImperativeHandle(ref, () => ({
1675
+ getValue: () => (value !== undefined ? value : internal),
1676
+ setValue: (v: string) => {
1677
+ if (value === undefined) setInternal(v);
1678
+ onChange?.(v);
1679
+ },
1680
+ getFile: () => file,
1681
+ reset: () => {
1682
+ setFile(null);
1683
+ if (value === undefined) setInternal('');
1684
+ setResetKey((k) => k + 1);
1685
+ onChange?.('');
1686
+ },
1687
+ focus: () => textRef.current?.focus(),
1688
+ }));
1689
+
1690
+ // 测量按钮宽度,作为文本框右边界(对齐 Ext onResize:text 宽 = wrap - button - offset)。
1691
+ // useLayoutEffect 在绘制前提交,无可见闪烁。
1692
+ useLayoutEffect(() => {
1693
+ if (btnRef.current) setBtnW(btnRef.current.offsetWidth);
1694
+ }, [btnText, buttonOnly]);
1695
+
1696
+ const wrapClass = [
1697
+ 'x-form-field-wrap',
1698
+ 'x-form-file-wrap',
1699
+ focused ? 'x-trigger-wrap-focus' : '',
1700
+ disabled ? 'x-item-disabled' : '',
1701
+ cls ?? '',
1702
+ ]
1703
+ .filter(Boolean)
1704
+ .join(' ');
1705
+
1706
+ const textWidth = Math.max(0, wrapW - btnW - buttonOffset);
1707
+
1708
+ return (
1709
+ <span style={{ position: 'relative', display: 'inline-block', ...style }}>
1710
+ <span
1711
+ className={wrapClass}
1712
+ style={{
1713
+ display: 'inline-block',
1714
+ width: buttonOnly && btnW ? btnW : wrapW,
1715
+ height: 22,
1716
+ }}
1717
+ >
1718
+ {!buttonOnly && (
1719
+ <input
1720
+ ref={textRef}
1721
+ id={id}
1722
+ type="text"
1723
+ className="x-form-field x-form-text x-form-file-text"
1724
+ value={current}
1725
+ readOnly
1726
+ disabled={disabled}
1727
+ placeholder={emptyText}
1728
+ style={{ position: 'absolute', left: 0, top: 0, zIndex: 3, height: 22, width: textWidth }}
1729
+ onFocus={() => setFocused(true)}
1730
+ onBlur={() => setFocused(false)}
1731
+ />
1732
+ )}
1733
+ <span
1734
+ ref={btnRef}
1735
+ className={['x-form-file-btn', buttonCfg?.cls ?? ''].filter(Boolean).join(' ')}
1736
+ style={{ position: 'absolute', right: 0, top: 0, zIndex: 1 }}
1737
+ >
1738
+ <Button text={btnText} scale="small" disabled={disabled} iconCls={buttonCfg?.iconCls} />
1739
+ <input
1740
+ key={resetKey}
1741
+ type="file"
1742
+ name={name || id}
1743
+ className="x-form-file"
1744
+ accept={accept}
1745
+ multiple={multiple}
1746
+ disabled={disabled}
1747
+ style={{
1748
+ position: 'absolute',
1749
+ inset: 0,
1750
+ width: '100%',
1751
+ height: '100%',
1752
+ zIndex: 2,
1753
+ opacity: 0,
1754
+ cursor: disabled ? 'default' : 'pointer',
1755
+ fontSize: 0,
1756
+ color: 'transparent',
1757
+ }}
1758
+ onChange={(e) => {
1759
+ const f = e.target.files?.[0];
1760
+ const v = f ? f.name : '';
1761
+ if (value === undefined) setInternal(v);
1762
+ setFile(f ?? null);
1763
+ if (v) {
1764
+ onChange?.(v);
1765
+ onFileSelected?.(v, f);
1766
+ }
1767
+ }}
1768
+ />
1769
+ </span>
1770
+ </span>
1771
+ </span>
1772
+ );
1773
+ },
1774
+ );
1775
+ FileUploadField.displayName = 'FileUploadField';
1776
+
1777
+ /* ------------------------------------------------------------------ */
1778
+ /* MultiSelect —— 复刻 Ext.ux.form.MultiSelect(FieldSet + 多选列表)*/
1779
+ /* onRender:fieldset.x-fieldset(.x-multiselect) */
1780
+ /* > legend.x-fieldset-header(legend 标题) */
1781
+ /* > div.x-fieldset-bwrap */
1782
+ /* (可选 tbar 工具栏) */
1783
+ /* > div.x-fieldset-body.ux-mselect(多选列表,对齐 Ext ListView)*/
1784
+ /* 选择逻辑对齐 Ext.ListView multiSelect:单击替换、ctrl 加选/减选、 */
1785
+ /* shift 范围选;getValue 返回 delimiter 分隔串、getRawValue 返回数组。 */
1786
+ /* ------------------------------------------------------------------ */
1787
+ export interface MultiSelectDataItem {
1788
+ value: string;
1789
+ text: string;
1790
+ }
1791
+ /** 数据源归一化输入:string[] | [value,text][] | {value,text}[] */
1792
+ export type MultiSelectData = Array<string | number | Array<string | number> | { value?: string | number; text?: string }>;
1793
+ export interface MultiSelectProps {
1794
+ id?: string;
1795
+ name?: string;
1796
+ /** 控件宽度(像素),对齐 Ext width(默认 200,比 Ext 默认 100 更实用) */
1797
+ width?: number;
1798
+ /** 列表可视高度(像素),对齐 Ext height(默认 100) */
1799
+ height?: number;
1800
+ /** 对齐 Ext legend:用 fieldset 包裹并显示标题 */
1801
+ legend?: string;
1802
+ /** 数据源:string[] / [value,text][] / {value,text}[] */
1803
+ data?: MultiSelectData;
1804
+ /** 受控值:delimiter 分隔字符串或数组;不提供时内部状态管理 */
1805
+ value?: string | string[];
1806
+ /** 值分隔符(对齐 Ext delimiter,默认 ',') */
1807
+ delimiter?: string;
1808
+ disabled?: boolean;
1809
+ /** 是否允许空选(对齐 Ext allowBlank,默认 true) */
1810
+ allowBlank?: boolean;
1811
+ /** 最少选中项数(对齐 Ext minSelections,默认 0) */
1812
+ minSelections?: number;
1813
+ /** 最多选中项数(对齐 Ext maxSelections,默认 Number.MAX_VALUE) */
1814
+ maxSelections?: number;
1815
+ /** 顶部工具栏:简化的按钮配置数组(对齐 Ext tbar) */
1816
+ tbar?: Array<{ text: string; onClick?: () => void; disabled?: boolean }>;
1817
+ cls?: string;
1818
+ style?: CSSProperties;
1819
+ /** 选中变化回调(回传选中 value 数组,对齐 Ext change) */
1820
+ onChange?: (values: string[]) => void;
1821
+ }
1822
+ /** 命令式句柄:对齐 Ext.ux.form.MultiSelect 的 getValue / setValue 与选择读取 */
1823
+ export interface MultiSelectHandle {
1824
+ /** delimiter 分隔的字符串(对齐 Ext getValue) */
1825
+ getValue: () => string;
1826
+ /** 选中 value 数组(对齐 Ext getRawValue) */
1827
+ getRawValue: () => string[];
1828
+ /** 设置选中:字符串(delimiter 分隔)或数组 */
1829
+ setValue: (v: string | string[]) => void;
1830
+ /** 选中项数据(value + text) */
1831
+ getSelectedItems: () => MultiSelectDataItem[];
1832
+ /** 清空选择(对齐 Ext reset) */
1833
+ clear: () => void;
1834
+ disable: () => void;
1835
+ enable: () => void;
1836
+ focus: () => void;
1837
+ }
1838
+ const normalizeMultiData = (data?: MultiSelectData): MultiSelectDataItem[] => {
1839
+ if (!data) return [];
1840
+ return data.map((d) => {
1841
+ if (typeof d === 'string' || typeof d === 'number') return { value: String(d), text: String(d) };
1842
+ if (Array.isArray(d)) return { value: String(d[0]), text: String(d[1] ?? d[0]) };
1843
+ return { value: String(d.value), text: String(d.text ?? d.value) };
1844
+ });
1845
+ };
1846
+ export const MultiSelect = forwardRef<MultiSelectHandle, MultiSelectProps>(function MultiSelect(
1847
+ {
1848
+ id,
1849
+ name,
1850
+ width = 200,
1851
+ height = 100,
1852
+ legend,
1853
+ data,
1854
+ value,
1855
+ delimiter = ',',
1856
+ disabled,
1857
+ tbar,
1858
+ cls,
1859
+ style,
1860
+ onChange,
1861
+ },
1862
+ ref,
1863
+ ) {
1864
+ const items = useMemo(() => normalizeMultiData(data), [data]);
1865
+ // value 对齐 Ext 的「初始默认选中项」语义:仅作为首次渲染的初始值,
1866
+ // 之后的选择由内部 selected 维护(对齐 Ext.ux.form.MultiSelect 内部态),
1867
+ // onChange 单向通知父级,父级无需回灌 value。
1868
+ const [selected, setSelected] = useState<number[]>(() => {
1869
+ if (value === undefined) return [];
1870
+ const arr = (Array.isArray(value) ? value : String(value).split(delimiter)).map(String);
1871
+ return items.reduce<number[]>((acc, it, i) => {
1872
+ if (arr.includes(String(it.value))) acc.push(i);
1873
+ return acc;
1874
+ }, []);
1875
+ });
1876
+ const lastIndex = useRef<number | null>(null);
1877
+ const [disabledState, setDisabledState] = useState(!!disabled);
1878
+ const bodyRef = useRef<HTMLDivElement>(null);
1879
+
1880
+ const emit = useCallback(
1881
+ (idx: number[]) => {
1882
+ const vals = idx.map((i) => items[i].value);
1883
+ onChange?.(vals);
1884
+ },
1885
+ [items, onChange],
1886
+ );
1887
+
1888
+ const selectByIndex = useCallback(
1889
+ (idx: number[]) => {
1890
+ setSelected(idx);
1891
+ emit(idx);
1892
+ },
1893
+ [emit],
1894
+ );
1895
+
1896
+ // value 同步:仅在 value 内容「相对上一次已应用值」真正发生变化时才回写 selected,
1897
+ // 避免父级每次渲染传入新的字面量数组(如 value={['b','d']})触发 effect 反复覆盖内部选择。
1898
+ const valueSigRef = useRef<string>('');
1899
+ const prevItemsRef = useRef(items);
1900
+ useEffect(() => {
1901
+ if (value === undefined) return;
1902
+ const arr = (Array.isArray(value) ? value : String(value).split(delimiter)).map(String);
1903
+ const sig = [...arr].sort().join('|');
1904
+ const itemsChanged = prevItemsRef.current !== items;
1905
+ if (valueSigRef.current === sig && !itemsChanged) return; // 同一 value 已应用过,保留内部编辑
1906
+ valueSigRef.current = sig;
1907
+ prevItemsRef.current = items;
1908
+ const idx = items.reduce<number[]>((acc, it, i) => {
1909
+ if (arr.includes(String(it.value))) acc.push(i);
1910
+ return acc;
1911
+ }, []);
1912
+ setSelected(idx);
1913
+ }, [value, items, delimiter]);
1914
+
1915
+ const handleClick = (i: number, e: React.MouseEvent) => {
1916
+ if (disabledState) return;
1917
+ if (e.ctrlKey || e.metaKey) {
1918
+ const cur = selected.includes(i) ? selected.filter((x) => x !== i) : [...selected, i];
1919
+ lastIndex.current = i;
1920
+ selectByIndex(cur);
1921
+ } else if (e.shiftKey && lastIndex.current != null) {
1922
+ const [a, b] = [lastIndex.current, i].sort((x, y) => x - y);
1923
+ const range: number[] = [];
1924
+ for (let k = a; k <= b; k++) range.push(k);
1925
+ selectByIndex(Array.from(new Set([...selected, ...range])));
1926
+ } else {
1927
+ lastIndex.current = i;
1928
+ selectByIndex([i]);
1929
+ }
1930
+ };
1931
+
1932
+ useImperativeHandle(
1933
+ ref,
1934
+ () => ({
1935
+ getValue: () => selected.map((i) => items[i].value).join(delimiter),
1936
+ getRawValue: () => selected.map((i) => items[i].value),
1937
+ setValue: (v) => {
1938
+ const arr = (Array.isArray(v) ? v : String(v).split(delimiter)).map(String);
1939
+ const idx = items.reduce<number[]>((acc, it, i) => {
1940
+ if (arr.includes(String(it.value))) acc.push(i);
1941
+ return acc;
1942
+ }, []);
1943
+ selectByIndex(idx);
1944
+ },
1945
+ getSelectedItems: () => selected.map((i) => items[i]),
1946
+ clear: () => selectByIndex([]),
1947
+ disable: () => setDisabledState(true),
1948
+ enable: () => setDisabledState(false),
1949
+ focus: () => bodyRef.current?.focus(),
1950
+ }),
1951
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1952
+ [selected, items, delimiter],
1953
+ );
1954
+
1955
+ const hiddenValue = selected.map((i) => items[i].value).join(delimiter);
1956
+
1957
+ const fsClass = ['x-fieldset', 'x-multiselect', disabledState ? 'x-item-disabled' : '', cls]
1958
+ .filter(Boolean)
1959
+ .join(' ');
1960
+
1961
+ return (
1962
+ <span style={{ display: 'inline-block', ...style }}>
1963
+ <fieldset className={fsClass} style={width != null ? { width } : undefined}>
1964
+ {legend != null && (
1965
+ <legend className="x-fieldset-header x-unselectable">
1966
+ <span className="x-fieldset-header-text">{legend}</span>
1967
+ </legend>
1968
+ )}
1969
+ <div className="x-fieldset-bwrap">
1970
+ {tbar && tbar.length > 0 && (
1971
+ <div className="x-multiselect-tbar">
1972
+ {tbar.map((b, bi) => (
1973
+ <Button
1974
+ key={bi}
1975
+ text={b.text}
1976
+ scale="small"
1977
+ disabled={b.disabled || disabledState}
1978
+ onClick={b.onClick}
1979
+ />
1980
+ ))}
1981
+ </div>
1982
+ )}
1983
+ <div
1984
+ className="x-fieldset-body ux-mselect"
1985
+ style={{ height, overflow: 'auto' }}
1986
+ ref={bodyRef}
1987
+ tabIndex={disabledState ? -1 : 0}
1988
+ >
1989
+ {items.map((it, i) => {
1990
+ const isSel = selected.includes(i);
1991
+ return (
1992
+ <div
1993
+ key={i}
1994
+ className={['ux-mselect-item', isSel ? 'ux-mselect-selected' : ''].filter(Boolean).join(' ')}
1995
+ onClick={(e) => handleClick(i, e)}
1996
+ onDoubleClick={() => emit(selected)}
1997
+ >
1998
+ {it.text}
1999
+ </div>
2000
+ );
2001
+ })}
2002
+ </div>
2003
+ </div>
2004
+ </fieldset>
2005
+ <input type="hidden" name={name || id} value={hiddenValue} disabled={disabledState} />
2006
+ </span>
2007
+ );
2008
+ });
2009
+ MultiSelect.displayName = 'MultiSelect';
2010
+
2011
+ /* ------------------------------------------------------------------ */
2012
+ /* Label —— 复刻 Ext.form.Label(独立文本标签,可关联字段 forId) */
2013
+ /* onRender:forId 存在时渲染 <label for>,否则 <span>; */
2014
+ /* text 经 htmlEncode,html 原样注入(对齐 Ext 二选一)。 */
2015
+ /* ------------------------------------------------------------------ */
2016
+ export interface LabelProps {
2017
+ id?: string;
2018
+ /** 关联的字段 id(对齐 Ext forId),设置后渲染为 <label for> 并可点击聚焦该字段 */
2019
+ forId?: string;
2020
+ /** 纯文本(对齐 Ext text),自动 HTML 转义 */
2021
+ text?: ReactNode;
2022
+ /** 原始 HTML(对齐 Ext html),与 text 二选一,优先级高于 text */
2023
+ html?: string;
2024
+ cls?: string;
2025
+ style?: CSSProperties;
2026
+ onClick?: (e: React.MouseEvent) => void;
2027
+ }
2028
+ export function Label({ id, forId, text, html, cls, style, onClick }: LabelProps) {
2029
+ const className = ['x-form-label-item', cls].filter(Boolean).join(' ');
2030
+ const common = { id, className, style, onClick };
2031
+ if (forId) {
2032
+ return (
2033
+ <label htmlFor={forId} {...common}>
2034
+ {html != null ? <span dangerouslySetInnerHTML={{ __html: html }} /> : text}
2035
+ </label>
2036
+ );
2037
+ }
2038
+ return (
2039
+ <span {...common}>
2040
+ {html != null ? <span dangerouslySetInnerHTML={{ __html: html }} /> : text}
2041
+ </span>
2042
+ );
2043
+ }
2044
+ Label.displayName = 'Label';
2045
+
2046
+ /* ------------------------------------------------------------------ */
2047
+ /* CompositeField —— 复刻 Ext.form.CompositeField */
2048
+ /* 将多个子字段渲染在同一行,共享单一 fieldLabel;getValue 返回子字段 */
2049
+ /* 值数组、setValue 接收数组按序写入(对齐 Ext combineErrors/items)。 */
2050
+ /* 子字段需为支持 getValue/setValue 的命令式句柄组件(本工程表单家族 */
2051
+ /* 字段均满足);无句柄的子节点(纯文本/span)会被安全跳过。 */
2052
+ /* ------------------------------------------------------------------ */
2053
+ export interface CompositeFieldHandle {
2054
+ /** 返回各子字段当前值(顺序与 children 一致;无句柄者返回 undefined) */
2055
+ getValue: () => unknown[];
2056
+ /** 按数组顺序将值写入各子字段(长度不必与子字段数一致,越界忽略) */
2057
+ setValue: (values: unknown[]) => void;
2058
+ /** 聚焦首个可聚焦子字段 */
2059
+ focus: () => void;
2060
+ }
2061
+ export interface CompositeFieldProps {
2062
+ id?: string;
2063
+ /** 共享标签(对齐 Ext fieldLabel) */
2064
+ fieldLabel?: ReactNode;
2065
+ labelAlign?: 'left' | 'top' | 'right';
2066
+ labelWidth?: number;
2067
+ labelPad?: number;
2068
+ labelSeparator?: string;
2069
+ hideLabel?: boolean;
2070
+ /** 子字段之间的分隔符(对齐 Ext separator,默认单个空格) */
2071
+ separator?: string;
2072
+ /** 子字段节点(任意表单字段组件或纯展示节点) */
2073
+ children?: ReactNode;
2074
+ cls?: string;
2075
+ style?: CSSProperties;
2076
+ }
2077
+ export const CompositeField = forwardRef<CompositeFieldHandle, CompositeFieldProps>(
2078
+ function CompositeField(
2079
+ {
2080
+ id,
2081
+ fieldLabel,
2082
+ labelAlign = 'left',
2083
+ labelWidth = 100,
2084
+ labelPad = 5,
2085
+ labelSeparator = ':',
2086
+ hideLabel,
2087
+ separator = ' ',
2088
+ children,
2089
+ cls,
2090
+ style,
2091
+ },
2092
+ ref,
2093
+ ) {
2094
+ const childRefs = useRef<unknown[]>([]);
2095
+
2096
+ const collect = (el: unknown, idx: number) => {
2097
+ childRefs.current[idx] = el;
2098
+ };
2099
+
2100
+ const nodes = Children.toArray(children).filter((c) => c != null);
2101
+
2102
+ const rendered = nodes.map((child, i) => {
2103
+ const sep =
2104
+ i > 0 ? (
2105
+ <span key={`sep-${i}`} className="x-composite-separator">
2106
+ {separator}
2107
+ </span>
2108
+ ) : null;
2109
+ let field: ReactNode = child;
2110
+ if (isValidElement(child)) {
2111
+ // 给子字段注入 ref(与既有 ref 合并),供句柄读取/写入
2112
+ const origRef = (child as ReactElement & { ref?: React.Ref<unknown> }).ref;
2113
+ field = cloneElement(child as ReactElement, {
2114
+ ref: (el: unknown) => {
2115
+ collect(el, i);
2116
+ if (typeof origRef === 'function') (origRef as (v: unknown) => void)(el);
2117
+ else if (origRef && typeof origRef === 'object')
2118
+ (origRef as React.MutableRefObject<unknown>).current = el;
2119
+ },
2120
+ } as Partial<unknown>);
2121
+ }
2122
+ return (
2123
+ <Fragment key={`c-${i}`}>
2124
+ {sep}
2125
+ {field}
2126
+ </Fragment>
2127
+ );
2128
+ });
2129
+
2130
+ useImperativeHandle(ref, () => ({
2131
+ getValue: () =>
2132
+ childRefs.current.map((r) =>
2133
+ r && typeof (r as { getValue?: () => unknown }).getValue === 'function'
2134
+ ? (r as { getValue: () => unknown }).getValue()
2135
+ : undefined,
2136
+ ),
2137
+ setValue: (values: unknown[]) => {
2138
+ values.forEach((v, i) => {
2139
+ const r = childRefs.current[i];
2140
+ if (r && typeof (r as { setValue?: (v: unknown) => void }).setValue === 'function')
2141
+ (r as { setValue: (v: unknown) => void }).setValue(v);
2142
+ });
2143
+ },
2144
+ focus: () => {
2145
+ const first = childRefs.current.find(
2146
+ (r) => r && typeof (r as { focus?: () => void }).focus === 'function',
2147
+ );
2148
+ if (first) (first as { focus: () => void }).focus();
2149
+ },
2150
+ }));
2151
+
2152
+ const isTop = labelAlign === 'top';
2153
+ const labelStyle: CSSProperties = isTop ? { width: 'auto' } : { width: `${labelWidth}px` };
2154
+ const itemClass = ['x-form-item', `x-form-label-${labelAlign}`]
2155
+ .filter(Boolean)
2156
+ .join(' ');
2157
+ const compositeClass = ['x-form-composite', cls].filter(Boolean).join(' ');
2158
+
2159
+ return (
2160
+ <div className={itemClass} tabIndex={-1} id={id}>
2161
+ {!hideLabel && fieldLabel != null && (
2162
+ <label style={labelStyle} className="x-form-item-label">
2163
+ {fieldLabel}
2164
+ {labelSeparator}
2165
+ </label>
2166
+ )}
2167
+ <div
2168
+ className="x-form-element"
2169
+ style={isTop ? { paddingLeft: 0 } : { paddingLeft: `${labelWidth + labelPad}px` }}
2170
+ >
2171
+ <div className={compositeClass} style={style}>
2172
+ {rendered}
2173
+ </div>
2174
+ </div>
2175
+ <div className="x-form-clear-left"></div>
2176
+ </div>
2177
+ );
2178
+ },
2179
+ );
2180
+ CompositeField.displayName = 'CompositeField';
2181
+
2182
+ /* ------------------------------------------------------------------ */
2183
+ /* ComboBox —— 复刻 TriggerField + Combo 下拉 */
2184
+ /* ------------------------------------------------------------------ */
2185
+ export interface ComboItem {
2186
+ value: string;
2187
+ text: string;
2188
+ }
2189
+ export interface ComboBoxProps {
2190
+ id?: string;
2191
+ name?: string;
2192
+ value?: string;
2193
+ width?: number;
2194
+ editable?: boolean;
2195
+ disabled?: boolean;
2196
+ /** 输入时前缀唯一匹配自动补全(等价 Ext typeAhead):键入串作为前缀匹配首项并补全剩余文本、高亮补全部分 */
2197
+ typeAhead?: boolean;
2198
+ data: ComboItem[];
2199
+ onChange?: (value: string) => void;
2200
+ /** 透传 ref:暴露 setData / addItem / removeItem / getData / setValue(对齐 Ext combo.getStore().loadData / add / remove / setValue)。 */
2201
+ ref?: React.Ref<ComboBoxHandle>;
2202
+ }
2203
+
2204
+ /** ComboBox 命令句柄(对齐 Ext ComboBox 数据源运行时编辑)。 */
2205
+ export interface ComboBoxHandle {
2206
+ /** 整体替换数据源(等价 Ext store.loadData),不破坏 value 受控场景 */
2207
+ setData: (items: ComboItem[]) => void;
2208
+ /** 追加一项到末尾 */
2209
+ addItem: (item: ComboItem) => void;
2210
+ /** 按 value 移除一项 */
2211
+ removeItem: (value: string) => void;
2212
+ /** 读取当前数据源(含动态追加/移除后的结果) */
2213
+ getData: () => ComboItem[];
2214
+ /** 程序化选中某项(等价 Ext combo.setValue) */
2215
+ setValue: (value: string) => void;
2216
+ /** 读取当前选中值(等价 Ext combo.getValue) */
2217
+ getValue: () => string;
2218
+ }
2219
+ export const ComboBox = forwardRef<ComboBoxHandle, ComboBoxProps>((
2220
+ {
2221
+ id,
2222
+ name,
2223
+ value,
2224
+ width,
2225
+ editable = true,
2226
+ disabled,
2227
+ typeAhead = false,
2228
+ data,
2229
+ onChange,
2230
+ },
2231
+ ref,
2232
+ ) => {
2233
+ const [internal, setInternal] = useState(value ?? '');
2234
+ const current = value !== undefined ? value : internal;
2235
+ const [focused, setFocused] = useState(false);
2236
+ const [open, setOpen] = useState(false);
2237
+ // 复刻 ExtJS Combo.selectByValue / onViewOver:展开时高亮选中值对应项,
2238
+ // 鼠标悬停或用方向键导航时高亮跟随移动,x-combo-selected 落到 highlight 项(而非 CSS :hover)。
2239
+ const [highlight, setHighlight] = useState<string | null>(null);
2240
+ // 用户输入的过滤串(null 表示未处于“正在输入”状态,应回显选中项的 display 文本)。
2241
+ // 对齐 Ext Combo 的 query:键入即按 text/value 过滤下拉项并展开。
2242
+ const [typed, setTyped] = useState<string | null>(null);
2243
+ // typeAhead 自动补全后的完整文本(null 表示无补全);输入框显示 completion ?? typed ?? resolved。
2244
+ const [completion, setCompletion] = useState<string | null>(null);
2245
+ // 浮层用 Portal 渲染到 body(与 Ext 一致),脱离 .x-panel-body 的 overflow:hidden 截断。
2246
+ const wrapRef = useRef<HTMLSpanElement>(null);
2247
+ const inputRef = useRef<HTMLInputElement>(null);
2248
+ // 待应用的补全文本选区(尾部高亮):在 layout effect 中落到真实 input 上。
2249
+ const pendingSel = useRef<[number, number] | null>(null);
2250
+ const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
2251
+ // 双轨:data prop 为静态数据源来源;dynamicData 为独立动态态(默认 null 表示沿用 prop)。
2252
+ // 对齐 Ext combo.getStore().loadData / add / remove。未调用动态方法时 managedData 恒等于 data,零回归。
2253
+ const [dynamicData, setDynamicData] = useState<ComboItem[] | null>(null);
2254
+ const managedData = useMemo<ComboItem[]>(
2255
+ () => dynamicData ?? data,
2256
+ [dynamicData, data],
2257
+ );
2258
+
2259
+ // 对齐 Ext Combo query:按 typed 过滤下拉项(text 或 value 包含,忽略大小写)。
2260
+ const filteredData = useMemo(() => {
2261
+ if (typed == null) return managedData;
2262
+ const q = typed.toLowerCase();
2263
+ return managedData.filter(
2264
+ (d) => d.text.toLowerCase().includes(q) || d.value.toLowerCase().includes(q),
2265
+ );
2266
+ }, [typed, managedData]);
2267
+
2268
+ // 展开时初始化高亮项:对齐 Ext 的 selectByValue(value),无匹配则选中首项。
2269
+ // 处于输入过滤态时,高亮过滤结果的首项。
2270
+ useEffect(() => {
2271
+ if (!open) return;
2272
+ if (typed !== null) {
2273
+ setHighlight(filteredData[0]?.value ?? null);
2274
+ } else {
2275
+ setHighlight(
2276
+ managedData.some((d) => d.value === current)
2277
+ ? current
2278
+ : managedData[0]?.value ?? null,
2279
+ );
2280
+ }
2281
+ }, [open, typed, current, managedData, filteredData]);
2282
+
2283
+ // 同步计算浮层坐标(在点击瞬间执行,避免首帧 coords 为 null 导致下拉不渲染)。
2284
+ // 对齐 Ext.Layer:用整数视口坐标 + fixed 定位;若字段靠近视口底部则向上翻转展开,避免溢出被裁剪。
2285
+ const openDropdown = useCallback(() => {
2286
+ const el = wrapRef.current;
2287
+ if (el) {
2288
+ const r = el.getBoundingClientRect();
2289
+ const estH = (Math.min(filteredData.length || 1, 10) * 22) + 6;
2290
+ const below = window.innerHeight - r.bottom >= estH;
2291
+ setCoords({
2292
+ top: Math.round(below ? r.bottom : r.top - estH),
2293
+ left: Math.round(r.left),
2294
+ });
2295
+ }
2296
+ setOpen(true);
2297
+ }, [filteredData]);
2298
+
2299
+ // 点击切换:已开则关(并清除过滤串),未开则同步打开并定位。
2300
+ const toggle = useCallback(() => {
2301
+ if (open) {
2302
+ setOpen(false);
2303
+ setTyped(null);
2304
+ return;
2305
+ }
2306
+ openDropdown();
2307
+ }, [open, openDropdown]);
2308
+
2309
+ // 对齐 Ext:点击组件外部、滚动或缩放窗口时关闭下拉(滚动关闭避免浮层悬空错位)。
2310
+ useEffect(() => {
2311
+ if (!open) return;
2312
+ const onDocDown = (e: MouseEvent) => {
2313
+ const target = e.target as HTMLElement;
2314
+ if (!target.closest('.x-form-field-trigger-wrap') && !target.closest('.x-combo-list')) {
2315
+ setOpen(false);
2316
+ }
2317
+ };
2318
+ const close = () => {
2319
+ setOpen(false);
2320
+ // 保留 completion(typeAhead 补全文本);仅清除输入过滤串,避免误把补全文本回退为选中项 display。
2321
+ setTyped(null);
2322
+ };
2323
+ document.addEventListener('mousedown', onDocDown);
2324
+ window.addEventListener('resize', close);
2325
+ // capture 捕获文档站 .docs-content 等任意滚动容器的平滑滚动
2326
+ window.addEventListener('scroll', close, true);
2327
+ return () => {
2328
+ document.removeEventListener('mousedown', onDocDown);
2329
+ window.removeEventListener('resize', close);
2330
+ window.removeEventListener('scroll', close, true);
2331
+ };
2332
+ }, [open]);
2333
+
2334
+ // 输入框回显:typeAhead 补全态显示补全文本,否则处于输入过滤态显示键入串,否则回显选中项的 display 文本(对齐 Ext)。
2335
+ const resolved = managedData.find((d) => d.value === current)?.text ?? current;
2336
+ const display = completion ?? (typed !== null ? typed : resolved);
2337
+
2338
+ // typeAhead:补全文本渲染后,把尾部补全部分高亮选中(对齐 Ext doTypeAhead 的 setSelectionRange)。
2339
+ useLayoutEffect(() => {
2340
+ if (pendingSel.current && inputRef.current) {
2341
+ const [s, e] = pendingSel.current;
2342
+ pendingSel.current = null;
2343
+ try {
2344
+ inputRef.current.focus();
2345
+ inputRef.current.setSelectionRange(s, e);
2346
+ } catch {
2347
+ /* 某些环境 input 尚未 mount,忽略 */
2348
+ }
2349
+ }
2350
+ });
2351
+
2352
+ const select = useCallback(
2353
+ (v: string) => {
2354
+ if (value === undefined) setInternal(v);
2355
+ setTyped(null);
2356
+ setCompletion(null);
2357
+ setOpen(false);
2358
+ onChange?.(v);
2359
+ },
2360
+ [value, onChange],
2361
+ );
2362
+
2363
+ // 命令句柄:运行时数据源编辑(对齐 Ext combo.getStore().loadData / add / remove / setValue)。
2364
+ // 源数据走 managedData(data prop + dynamicData 双轨),不破坏 value 受控场景。
2365
+ useImperativeHandle(ref, () => ({
2366
+ setData: (items: ComboItem[]) => {
2367
+ setDynamicData(items);
2368
+ // 清空正在输入的过滤串,避免旧 query 滞留。
2369
+ setTyped(null);
2370
+ },
2371
+ addItem: (item: ComboItem) => {
2372
+ setDynamicData((prev) => [...(prev ?? data), item]);
2373
+ },
2374
+ removeItem: (val: string) => {
2375
+ setDynamicData((prev) => (prev ?? data).filter((d) => d.value !== val));
2376
+ },
2377
+ getData: () => managedData,
2378
+ setValue: (val: string) => select(val),
2379
+ getValue: () => current,
2380
+ }), [managedData, data, select, current]);
2381
+
2382
+ // 键盘导航:对齐 Ext Combo.onKeyDown 的 selectNext/selectPrev(到边界回环)/ Enter 选中 / Esc 收起。
2383
+ // 导航范围基于过滤后的 filteredData。
2384
+ const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
2385
+ if (!open) {
2386
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
2387
+ e.preventDefault();
2388
+ openDropdown();
2389
+ }
2390
+ return;
2391
+ }
2392
+ const idx = filteredData.findIndex((d) => d.value === highlight);
2393
+ if (e.key === 'ArrowDown') {
2394
+ e.preventDefault();
2395
+ const next = filteredData.length ? (idx + 1) % filteredData.length : 0;
2396
+ setHighlight(filteredData[next]?.value ?? null);
2397
+ } else if (e.key === 'ArrowUp') {
2398
+ e.preventDefault();
2399
+ const prev = filteredData.length ? (idx - 1 + filteredData.length) % filteredData.length : 0;
2400
+ setHighlight(filteredData[prev]?.value ?? null);
2401
+ } else if (e.key === 'Enter') {
2402
+ e.preventDefault();
2403
+ if (highlight != null) select(highlight);
2404
+ } else if (e.key === 'Escape') {
2405
+ e.preventDefault();
2406
+ setOpen(false);
2407
+ }
2408
+ };
2409
+
2410
+ const wrapClass = ['x-form-field-wrap', 'x-form-field-trigger-wrap', focused ? 'x-trigger-wrap-focus' : '']
2411
+ .filter(Boolean)
2412
+ .join(' ');
2413
+
2414
+ return (
2415
+ <span style={{ position: 'relative', display: 'inline-block' }}>
2416
+ <span ref={wrapRef} className={wrapClass} style={{ display: 'flex', alignItems: 'stretch', ...(width ? { width } : {}) }}>
2417
+ <input
2418
+ ref={inputRef}
2419
+ id={id}
2420
+ name={name}
2421
+ type="text"
2422
+ className="x-form-field x-form-text"
2423
+ value={display}
2424
+ readOnly={!editable}
2425
+ disabled={disabled}
2426
+ style={{ flex: '1 1 auto', minWidth: 0, height: 22, boxSizing: 'border-box' }}
2427
+ onFocus={() => setFocused(true)}
2428
+ onBlur={() => setFocused(false)}
2429
+ onClick={toggle}
2430
+ onKeyDown={onKeyDown}
2431
+ onChange={(e) => {
2432
+ if (!editable) return;
2433
+ const v = e.target.value;
2434
+ // 对齐 Ext Combo query:键入即过滤下拉项并展开
2435
+ setTyped(v);
2436
+ openDropdown();
2437
+ if (value === undefined) setInternal(v);
2438
+ // typeAhead:前缀唯一匹配则补全剩余文本并高亮补全部分(对齐 Ext doTypeAhead)
2439
+ if (typeAhead) {
2440
+ if (v) {
2441
+ const m = managedData.find(
2442
+ (d) =>
2443
+ d.text.toLowerCase().startsWith(v.toLowerCase()) &&
2444
+ d.text.length > v.length,
2445
+ );
2446
+ if (m) {
2447
+ setCompletion(m.text);
2448
+ pendingSel.current = [v.length, m.text.length];
2449
+ } else {
2450
+ setCompletion(null);
2451
+ }
2452
+ } else {
2453
+ setCompletion(null);
2454
+ }
2455
+ }
2456
+ }}
2457
+ />
2458
+ <img
2459
+ className="x-form-trigger x-form-arrow-trigger"
2460
+ src={BLANK_IMG}
2461
+ alt=""
2462
+ style={{ flex: '0 0 auto' }}
2463
+ onClick={() => !disabled && toggle()}
2464
+ />
2465
+ </span>
2466
+ {open && coords &&
2467
+ createPortal(
2468
+ <div
2469
+ className="x-combo-list x-layer"
2470
+ style={{ position: 'fixed', visibility: 'visible', zIndex: 15000, top: coords.top, left: coords.left, width: width ? width : undefined, boxSizing: 'border-box' }}
2471
+ >
2472
+ <div className="x-combo-list-inner">
2473
+ {filteredData.map((d) => (
2474
+ <div
2475
+ key={d.value}
2476
+ className={[
2477
+ 'x-combo-list-item',
2478
+ d.value === highlight ? 'x-combo-selected' : '',
2479
+ ]
2480
+ .filter(Boolean)
2481
+ .join(' ')}
2482
+ onMouseEnter={() => setHighlight(d.value)}
2483
+ onClick={() => select(d.value)}
2484
+ >
2485
+ {d.text}
2486
+ </div>
2487
+ ))}
2488
+ </div>
2489
+ </div>,
2490
+ getPortalRoot(),
2491
+ )}
2492
+ </span>
2493
+ );
2494
+ });
2495
+
2496
+ /* ------------------------------------------------------------------ */
2497
+ /* SelectBox —— 复刻 Ext.ux.form.SelectBox(继承 ComboBox) */
2498
+ /* 差异行为(对齐 examples/ux/SelectBox.js): */
2499
+ /* 1) 强制 editable:false / forceSelection / triggerAction:'all' / */
2500
+ /* mode:'local';输入框只读,点击触发器展开全量列表。 */
2501
+ /* 2) 键盘逐字搜索(type-through):聚焦时键入字母按 displayField 前缀 */
2502
+ /* 匹配并跳转到对应项;重复同字母从上一命中后继续(lastSearchTerm)。 */
2503
+ /* 3) 列表「按下拖动、松开选择」:对齐原生 SELECT,onMouseUp 才提交选择 */
2504
+ /* (而非 ComboBox 的 onClick 立即选择),可拖动滑过列表松手落定。 */
2505
+ /* 4) HOME/END/PAGEUP/PAGEDOWN 导航。 */
2506
+ /* 注:原 Ext 每次按键会立即选中且收起下拉(历史 quirk);本项目保持下拉 */
2507
+ /* 展开以便连续输入定位,属保真等效优化。DOM/class 复用 ComboBox 套件。 */
2508
+ /* ------------------------------------------------------------------ */
2509
+ export interface SelectBoxProps {
2510
+ id?: string;
2511
+ name?: string;
2512
+ value?: string;
2513
+ width?: number;
2514
+ disabled?: boolean;
2515
+ emptyText?: string;
2516
+ placeholder?: string;
2517
+ /** 数据源(对齐 Ext local store) */
2518
+ data: ComboItem[];
2519
+ /** 搜索复位延时(对齐 Ext searchResetDelay,默认 1000ms) */
2520
+ searchResetDelay?: number;
2521
+ onChange?: (value: string) => void;
2522
+ }
2523
+ /** 命令式句柄:对齐 ComboBox(getValue / setValue / 数据源运行时编辑)。 */
2524
+ export type SelectBoxHandle = ComboBoxHandle;
2525
+ export const SelectBox = forwardRef<SelectBoxHandle, SelectBoxProps>((
2526
+ {
2527
+ id,
2528
+ name,
2529
+ value,
2530
+ width,
2531
+ disabled,
2532
+ emptyText,
2533
+ placeholder,
2534
+ data,
2535
+ searchResetDelay = 1000,
2536
+ onChange,
2537
+ },
2538
+ ref,
2539
+ ) => {
2540
+ const isControlled = value !== undefined;
2541
+ const [internal, setInternal] = useState(value ?? (data[0]?.value ?? ''));
2542
+ const current = isControlled ? value! : internal;
2543
+ const [focused, setFocused] = useState(false);
2544
+ const [open, setOpen] = useState(false);
2545
+ const [highlight, setHighlight] = useState<string | null>(null);
2546
+ const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
2547
+ const [dynamicData, setDynamicData] = useState<ComboItem[] | null>(null);
2548
+ const managedData = useMemo<ComboItem[]>(() => dynamicData ?? data, [dynamicData, data]);
2549
+ const wrapRef = useRef<HTMLSpanElement>(null);
2550
+ const inputRef = useRef<HTMLInputElement>(null);
2551
+ // 镜像最新 value,供命令式句柄同步读取(避免 React setState 异步导致 getValue 读旧值)
2552
+ const valueRef = useRef<string>(current);
2553
+ valueRef.current = current;
2554
+ // 键盘搜索态(对齐 Ext lastSearchTerm / lastSelectedIndex,1-based)
2555
+ const lastTerm = useRef<string | false>(false);
2556
+ const lastIndex = useRef<number>(0);
2557
+ const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
2558
+
2559
+ const resetSearch = useCallback(() => {
2560
+ lastTerm.current = false;
2561
+ lastIndex.current = 0;
2562
+ }, []);
2563
+
2564
+ const openDropdown = useCallback(() => {
2565
+ const el = wrapRef.current;
2566
+ if (el) {
2567
+ const r = el.getBoundingClientRect();
2568
+ const estH = (Math.min(managedData.length || 1, 10) * 22) + 6;
2569
+ const below = window.innerHeight - r.bottom >= estH;
2570
+ setCoords({ top: Math.round(below ? r.bottom : r.top - estH), left: Math.round(r.left) });
2571
+ }
2572
+ setOpen(true);
2573
+ if (highlight == null) setHighlight(current || managedData[0]?.value || null);
2574
+ }, [managedData, highlight, current]);
2575
+
2576
+ const closeDropdown = useCallback(() => {
2577
+ setOpen(false);
2578
+ resetSearch();
2579
+ }, [resetSearch]);
2580
+
2581
+ // 提交选中(对齐 Ext onSelect:setValue + 收起 + 触发 select)
2582
+ const commit = useCallback(
2583
+ (v: string) => {
2584
+ if (!isControlled) setInternal(v);
2585
+ setHighlight(v);
2586
+ setOpen(false);
2587
+ resetSearch();
2588
+ onChange?.(v);
2589
+ },
2590
+ [isControlled, onChange, resetSearch],
2591
+ );
2592
+
2593
+ // 仅高亮预览(键盘逐字搜索时保持下拉展开,便于连续定位)
2594
+ const preview = useCallback(
2595
+ (v: string) => {
2596
+ if (!isControlled) setInternal(v);
2597
+ setHighlight(v);
2598
+ onChange?.(v);
2599
+ },
2600
+ [isControlled, onChange],
2601
+ );
2602
+
2603
+ // 键盘逐字搜索(前缀匹配 displayField,重复同字母从上一命中后继续)
2604
+ const typeSearch = useCallback(
2605
+ (key: string) => {
2606
+ const k = key.toLowerCase();
2607
+ const start = lastTerm.current === key ? lastIndex.current : 0;
2608
+ const n = managedData.length;
2609
+ let found = -1;
2610
+ for (let i = 0; i < n; i++) {
2611
+ const idx = (start + i) % n;
2612
+ if (managedData[idx].text.toLowerCase().startsWith(k)) {
2613
+ found = idx;
2614
+ break;
2615
+ }
2616
+ }
2617
+ if (found !== -1) {
2618
+ const v = managedData[found].value;
2619
+ lastTerm.current = key;
2620
+ lastIndex.current = found + 1;
2621
+ preview(v);
2622
+ if (resetTimer.current) clearTimeout(resetTimer.current);
2623
+ resetTimer.current = setTimeout(() => resetSearch(), searchResetDelay);
2624
+ }
2625
+ },
2626
+ [managedData, preview, resetSearch, searchResetDelay],
2627
+ );
2628
+
2629
+ useEffect(() => {
2630
+ if (resetTimer.current) return () => clearTimeout(resetTimer.current ?? undefined);
2631
+ }, [open]);
2632
+
2633
+ useEffect(() => {
2634
+ if (!open) return;
2635
+ const onDocDown = (e: MouseEvent) => {
2636
+ const t = e.target as HTMLElement;
2637
+ if (!t.closest('.x-form-field-trigger-wrap') && !t.closest('.x-combo-list')) setOpen(false);
2638
+ };
2639
+ const close = () => setOpen(false);
2640
+ document.addEventListener('mousedown', onDocDown);
2641
+ window.addEventListener('resize', close);
2642
+ window.addEventListener('scroll', close, true);
2643
+ return () => {
2644
+ document.removeEventListener('mousedown', onDocDown);
2645
+ window.removeEventListener('resize', close);
2646
+ window.addEventListener('scroll', close, true);
2647
+ };
2648
+ }, [open]);
2649
+
2650
+ const moveHighlight = useCallback(
2651
+ (delta: number, pageSize = false) => {
2652
+ const n = managedData.length;
2653
+ if (!n) return;
2654
+ const cur = managedData.findIndex((d) => d.value === highlight);
2655
+ const step = pageSize ? Math.max(1, Math.min(10, n)) : 1;
2656
+ const next = ((cur === -1 ? 0 : cur) + delta * step + n) % n;
2657
+ setHighlight(managedData[next].value);
2658
+ },
2659
+ [managedData, highlight],
2660
+ );
2661
+
2662
+ const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
2663
+ const printable = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
2664
+ if (!open) {
2665
+ if (['ArrowDown', 'ArrowUp', 'Home', 'End', 'PageDown', 'PageUp'].includes(e.key) || printable) {
2666
+ e.preventDefault();
2667
+ openDropdown();
2668
+ if (e.key === 'Home') { const f = managedData[0]; if (f) { preview(f.value); setHighlight(f.value); } }
2669
+ else if (e.key === 'End') { const l = managedData[managedData.length - 1]; if (l) { preview(l.value); setHighlight(l.value); } }
2670
+ else if (printable) typeSearch(e.key);
2671
+ }
2672
+ return;
2673
+ }
2674
+ if (e.key === 'ArrowDown') { e.preventDefault(); moveHighlight(1); }
2675
+ else if (e.key === 'ArrowUp') { e.preventDefault(); moveHighlight(-1); }
2676
+ else if (e.key === 'PageDown') {
2677
+ e.preventDefault();
2678
+ const cur = managedData.findIndex((d) => d.value === (highlight ?? current));
2679
+ const idx = Math.min(managedData.length - 1, cur + Math.max(1, Math.min(10, managedData.length)));
2680
+ preview(managedData[idx].value); setHighlight(managedData[idx].value);
2681
+ }
2682
+ else if (e.key === 'PageUp') {
2683
+ e.preventDefault();
2684
+ const cur = managedData.findIndex((d) => d.value === (highlight ?? current));
2685
+ const idx = Math.max(0, cur - Math.max(1, Math.min(10, managedData.length)));
2686
+ preview(managedData[idx].value); setHighlight(managedData[idx].value);
2687
+ }
2688
+ else if (e.key === 'Home') { e.preventDefault(); if (managedData[0]) { preview(managedData[0].value); setHighlight(managedData[0].value); } }
2689
+ else if (e.key === 'End') { e.preventDefault(); const last = managedData[managedData.length - 1]; if (last) { preview(last.value); setHighlight(last.value); } }
2690
+ else if (e.key === 'Enter') { e.preventDefault(); if (highlight != null) commit(highlight); }
2691
+ else if (e.key === 'Escape') { e.preventDefault(); closeDropdown(); }
2692
+ else if (printable) { e.preventDefault(); typeSearch(e.key); }
2693
+ };
2694
+
2695
+ const resolved = managedData.find((d) => d.value === current)?.text ?? current;
2696
+ const wrapClass = ['x-form-field-wrap', 'x-form-field-trigger-wrap', focused ? 'x-trigger-wrap-focus' : '']
2697
+ .filter(Boolean)
2698
+ .join(' ');
2699
+
2700
+ // 命令式句柄(对齐 Ext.ux.form.SelectBox 继承 ComboBox 的 getValue / setValue / setData / addItem / removeItem / getData)
2701
+ useImperativeHandle(ref, () => ({
2702
+ setData: (items: ComboItem[]) => setDynamicData(items),
2703
+ addItem: (item: ComboItem) => setDynamicData((prev) => [...(prev ?? data), item]),
2704
+ removeItem: (val: string) => setDynamicData((prev) => (prev ?? data).filter((d) => d.value !== val)),
2705
+ getData: () => managedData,
2706
+ setValue: (val: string) => commit(val),
2707
+ getValue: () => valueRef.current,
2708
+ }), [managedData, data, commit]);
2709
+
2710
+ return (
2711
+ <span style={{ position: 'relative', display: 'inline-block' }}>
2712
+ <span ref={wrapRef} className={wrapClass} style={{ display: 'flex', alignItems: 'stretch', ...(width ? { width } : {}) }}>
2713
+ <input
2714
+ ref={inputRef}
2715
+ id={id}
2716
+ name={name}
2717
+ type="text"
2718
+ className="x-form-field x-form-text"
2719
+ value={resolved}
2720
+ readOnly
2721
+ disabled={disabled}
2722
+ placeholder={placeholder ?? emptyText}
2723
+ style={{ flex: '1 1 auto', minWidth: 0, height: 22, boxSizing: 'border-box' }}
2724
+ onFocus={() => setFocused(true)}
2725
+ onBlur={() => setFocused(false)}
2726
+ onClick={() => !disabled && openDropdown()}
2727
+ onKeyDown={onKeyDown}
2728
+ />
2729
+ <img
2730
+ className="x-form-trigger x-form-arrow-trigger"
2731
+ src={BLANK_IMG}
2732
+ alt=""
2733
+ style={{ flex: '0 0 auto' }}
2734
+ onMouseDown={(e) => {
2735
+ if (disabled) return;
2736
+ e.preventDefault();
2737
+ if (open) closeDropdown();
2738
+ else openDropdown();
2739
+ }}
2740
+ />
2741
+ </span>
2742
+ {open && coords &&
2743
+ createPortal(
2744
+ <div
2745
+ className="x-combo-list x-layer"
2746
+ style={{ position: 'fixed', visibility: 'visible', zIndex: 15000, top: coords.top, left: coords.left, width: width ? width : undefined, boxSizing: 'border-box' }}
2747
+ onMouseUp={() => {
2748
+ // 松开即落定(对齐 Ext innerList.on('mouseup', onViewClick))
2749
+ if (highlight != null) commit(highlight);
2750
+ }}
2751
+ >
2752
+ <div className="x-combo-list-inner">
2753
+ {managedData.map((d) => (
2754
+ <div
2755
+ key={d.value}
2756
+ className={[
2757
+ 'x-combo-list-item',
2758
+ d.value === highlight ? 'x-combo-selected' : '',
2759
+ ]
2760
+ .filter(Boolean)
2761
+ .join(' ')}
2762
+ onMouseDown={() => setHighlight(d.value)}
2763
+ onMouseEnter={() => {
2764
+ setHighlight(d.value);
2765
+ lastIndex.current = managedData.findIndex((x) => x.value === d.value) + 1;
2766
+ }}
2767
+ >
2768
+ {d.text}
2769
+ </div>
2770
+ ))}
2771
+ </div>
2772
+ </div>,
2773
+ getPortalRoot(),
2774
+ )}
2775
+ </span>
2776
+ );
2777
+ });
2778
+ SelectBox.displayName = 'SelectBox';
2779
+
2780
+ /* ------------------------------------------------------------------ */
2781
+ /* TimeField —— 复刻 Ext.form.TimeField(继承 ComboBox,时间点下拉) */
2782
+ /* 默认 format 'g:i A'、increment 30 分钟、区间 12:00 AM ~ 11:30 PM。 */
2783
+ /* 行为契约:与 ComboBox 完全一致(展开/选中/外部点击关闭), */
2784
+ /* 仅 data 由 min/max/increment 生成。trigger 复用 x-form-arrow-trigger。 */
2785
+ /* ------------------------------------------------------------------ */
2786
+ export interface TimeFieldProps {
2787
+ id?: string;
2788
+ name?: string;
2789
+ value?: string;
2790
+ width?: number;
2791
+ disabled?: boolean;
2792
+ /** 分钟增量,默认 30 */
2793
+ increment?: number;
2794
+ /** 起始时间('g:i A',默认 '12:00 AM') */
2795
+ minValue?: string;
2796
+ /** 结束时间(默认 '11:30 PM') */
2797
+ maxValue?: string;
2798
+ /** 显示格式,默认 'g:i A'(如 10:00 AM) */
2799
+ format?: string;
2800
+ onChange?: (value: string) => void;
2801
+ }
2802
+ function fmtTime(h: number, m: number, format: string) {
2803
+ if (format === 'H:i') return `${h < 10 ? '0' + h : h}:${m < 10 ? '0' + m : m}`;
2804
+ // 默认 g:i A
2805
+ const period = h < 12 ? 'AM' : 'PM';
2806
+ const hh = h % 12 === 0 ? 12 : h % 12;
2807
+ return `${hh}:${m < 10 ? '0' + m : m} ${period}`;
2808
+ }
2809
+ export const TimeField = forwardRef<ComboBoxHandle, TimeFieldProps>(
2810
+ (
2811
+ {
2812
+ id,
2813
+ name,
2814
+ value,
2815
+ width,
2816
+ disabled,
2817
+ increment = 30,
2818
+ minValue = '12:00 AM',
2819
+ maxValue = '11:30 PM',
2820
+ format = 'g:i A',
2821
+ onChange,
2822
+ },
2823
+ ref,
2824
+ ) => {
2825
+ // 解析 12小时制 'g:i A' 边界为分钟数
2826
+ const parseBound = (s: string) => {
2827
+ const m = s.trim().match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
2828
+ if (!m) return 0;
2829
+ let h = parseInt(m[1], 10) % 12;
2830
+ if (m[3].toUpperCase() === 'PM') h += 12;
2831
+ return h * 60 + parseInt(m[2], 10);
2832
+ };
2833
+ const start = parseBound(minValue);
2834
+ const end = parseBound(maxValue);
2835
+ const data: ComboItem[] = [];
2836
+ for (let t = start; t <= end; t += increment) {
2837
+ const h = Math.floor(t / 60);
2838
+ const m = t % 60;
2839
+ const label = fmtTime(h, m, format);
2840
+ data.push({ value: label, text: label });
2841
+ }
2842
+ // 透传 handle 到内部 ComboBox:TimeField 继承 ComboBox 的动态数据源能力
2843
+ // (setData/addItem/removeItem/getData/setValue),对齐 Ext TimeField 的 store 运行时编辑。
2844
+ return (
2845
+ <ComboBox
2846
+ ref={ref}
2847
+ id={id}
2848
+ name={name}
2849
+ value={value}
2850
+ width={width}
2851
+ disabled={disabled}
2852
+ data={data}
2853
+ onChange={onChange}
2854
+ />
2855
+ );
2856
+ },
2857
+ );
2858
+ TimeField.displayName = 'TimeField';
2859
+
2860
+ /* ------------------------------------------------------------------ */
2861
+ /* DateField —— 复刻 Ext.form.DateField(TriggerField + DateMenu) */
2862
+ /* onRender:span.x-form-field-wrap.x-form-field-trigger-wrap */
2863
+ /* > input.x-form-text + img.x-form-trigger.x-form-date-trigger */
2864
+ /* 触发打开 DatePicker 浮层(x-menu.x-date-menu.x-menu-floating.x-layer)。*/
2865
+ /* 选中后 format('m/d/Y') 写入 input 并关闭;外部点击关闭。 */
2866
+ /* ------------------------------------------------------------------ */
2867
+ function pad2(n: number) {
2868
+ return n < 10 ? `0${n}` : `${n}`;
2869
+ }
2870
+ function formatDate(d: Date, fmt: string) {
2871
+ return fmt
2872
+ .replace(/Y/g, String(d.getFullYear()))
2873
+ .replace(/y/g, String(d.getFullYear()).slice(2))
2874
+ .replace(/m/g, pad2(d.getMonth() + 1))
2875
+ .replace(/d/g, pad2(d.getDate()));
2876
+ }
2877
+ function parseDate(s?: string) {
2878
+ if (!s) return null;
2879
+ const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
2880
+ if (m) return new Date(+m[3], +m[1] - 1, +m[2]);
2881
+ const m2 = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
2882
+ if (m2) return new Date(+m2[1], +m2[2] - 1, +m2[3]);
2883
+ return null;
2884
+ }
2885
+
2886
+ export interface DateFieldProps {
2887
+ id?: string;
2888
+ name?: string;
2889
+ value?: string;
2890
+ width?: number;
2891
+ editable?: boolean;
2892
+ disabled?: boolean;
2893
+ /** 日期格式,默认 'm/d/Y'(如 05/04/2006) */
2894
+ format?: string;
2895
+ /** 最小可选日期(早于则 x-date-disabled) */
2896
+ minDate?: Date | null;
2897
+ /** 最大可选日期 */
2898
+ maxDate?: Date | null;
2899
+ /** 禁用的星期索引数组,如 [0,6] */
2900
+ disabledDays?: number[];
2901
+ onChange?: (value: string) => void;
2902
+ }
2903
+ /** 命令式句柄:对齐 Ext.DateField 的运行时 setMinValue/setMaxValue/setDisabledDays 与 getValue/setValue */
2904
+ export interface DateFieldHandle {
2905
+ setMinDate: (d: Date | null) => void;
2906
+ setMaxDate: (d: Date | null) => void;
2907
+ setDisabledDays: (days: number[]) => void;
2908
+ setValue: (v: string) => void;
2909
+ getValue: () => string;
2910
+ }
2911
+ export const DateField = forwardRef<DateFieldHandle, DateFieldProps>(
2912
+ (
2913
+ {
2914
+ id,
2915
+ name,
2916
+ value,
2917
+ width,
2918
+ editable = true,
2919
+ disabled,
2920
+ format = 'm/d/Y',
2921
+ minDate,
2922
+ maxDate,
2923
+ disabledDays,
2924
+ onChange,
2925
+ },
2926
+ ref,
2927
+ ) => {
2928
+ const [internal, setInternal] = useState(value ?? '');
2929
+ const current = value !== undefined ? value : internal;
2930
+ const [focused, setFocused] = useState(false);
2931
+ const [open, setOpen] = useState(false);
2932
+ const [triggerOver, setTriggerOver] = useState(false);
2933
+ // 双轨:min/max/disabledDays 既接受静态 prop,也支持运行时通过句柄动态改写。
2934
+ const [dynMin, setDynMin] = useState<Date | null | undefined>(minDate);
2935
+ const [dynMax, setDynMax] = useState<Date | null | undefined>(maxDate);
2936
+ const [dynDisabledDays, setDynDisabledDays] = useState<number[] | undefined>(disabledDays);
2937
+ // 同步静态 prop 变化(演示中主要通过句柄改写,这里保证 prop 驱动也生效)
2938
+ useEffect(() => setDynMin(minDate), [minDate]);
2939
+ useEffect(() => setDynMax(maxDate), [maxDate]);
2940
+ useEffect(() => setDynDisabledDays(disabledDays), [disabledDays]);
2941
+
2942
+ useImperativeHandle(ref, () => ({
2943
+ setMinDate: (d) => setDynMin(d),
2944
+ setMaxDate: (d) => setDynMax(d),
2945
+ setDisabledDays: (days) => setDynDisabledDays(days),
2946
+ setValue: (v) => {
2947
+ if (value === undefined) setInternal(v);
2948
+ onChange?.(v);
2949
+ },
2950
+ getValue: () => (value !== undefined ? value : internal),
2951
+ }));
2952
+ // 浮层用 Portal 渲染到 body(与 Ext 的 DateMenu.renderTo=document.body 一致),
2953
+ // 脱离 .x-panel-body 的 overflow:hidden 截断;用视口坐标 fixed 定位到 trigger 正下方。
2954
+ const wrapRef = useRef<HTMLSpanElement>(null);
2955
+ const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
2956
+
2957
+ // 同步计算浮层坐标(在点击瞬间执行,避免首帧 coords 为 null 导致日历不渲染)。
2958
+ // 对齐 Ext.Layer:整数视口坐标 + fixed 定位;字段靠近视口底部时向上翻转展开,避免溢出被裁剪。
2959
+ const openDropdown = useCallback(() => {
2960
+ const el = wrapRef.current;
2961
+ if (el) {
2962
+ const r = el.getBoundingClientRect();
2963
+ // DatePicker(含头部/网格/底部)估算高度约 210px。
2964
+ const estH = 210;
2965
+ const below = window.innerHeight - r.bottom >= estH;
2966
+ setCoords({
2967
+ top: Math.round(below ? r.bottom : r.top - estH),
2968
+ left: Math.round(r.left),
2969
+ });
2970
+ }
2971
+ setOpen(true);
2972
+ }, []);
2973
+
2974
+ // 点击切换:已开则关,未开则同步打开并定位。
2975
+ const toggle = useCallback(() => {
2976
+ if (open) {
2977
+ setOpen(false);
2978
+ return;
2979
+ }
2980
+ openDropdown();
2981
+ }, [open, openDropdown]);
2982
+
2983
+ // 对齐 Ext:点击组件外部、滚动或缩放窗口时关闭浮层(滚动关闭避免浮层悬空错位)。
2984
+ useEffect(() => {
2985
+ if (!open) return;
2986
+ const onDocDown = (e: MouseEvent) => {
2987
+ const target = e.target as HTMLElement;
2988
+ if (
2989
+ !target.closest('.x-form-field-trigger-wrap') &&
2990
+ !target.closest('.x-date-menu') &&
2991
+ !target.closest('.x-date-picker')
2992
+ ) {
2993
+ setOpen(false);
2994
+ }
2995
+ };
2996
+ const close = () => setOpen(false);
2997
+ document.addEventListener('mousedown', onDocDown);
2998
+ window.addEventListener('resize', close);
2999
+ // capture 捕获文档站 .docs-content 等任意滚动容器的平滑滚动
3000
+ window.addEventListener('scroll', close, true);
3001
+ return () => {
3002
+ document.removeEventListener('mousedown', onDocDown);
3003
+ window.removeEventListener('resize', close);
3004
+ window.removeEventListener('scroll', close, true);
3005
+ };
3006
+ }, [open]);
3007
+
3008
+ const selected = parseDate(current);
3009
+
3010
+ const wrapClass = ['x-form-field-wrap', 'x-form-field-trigger-wrap', focused ? 'x-trigger-wrap-focus' : '']
3011
+ .filter(Boolean)
3012
+ .join(' ');
3013
+
3014
+ return (
3015
+ <span style={{ position: 'relative', display: 'inline-block' }}>
3016
+ <span ref={wrapRef} className={wrapClass} style={{ display: 'flex', alignItems: 'stretch', ...(width ? { width } : {}) }}>
3017
+ <input
3018
+ id={id}
3019
+ name={name}
3020
+ type="text"
3021
+ className="x-form-field x-form-text"
3022
+ value={current}
3023
+ readOnly={!editable}
3024
+ disabled={disabled}
3025
+ style={{ flex: '1 1 auto', minWidth: 0, height: 22, boxSizing: 'border-box' }}
3026
+ onFocus={() => setFocused(true)}
3027
+ onBlur={() => setFocused(false)}
3028
+ onClick={() => !disabled && toggle()}
3029
+ onChange={(e) => {
3030
+ if (value === undefined) setInternal(e.target.value);
3031
+ onChange?.(e.target.value);
3032
+ }}
3033
+ />
3034
+ <img
3035
+ className={['x-form-trigger', 'x-form-date-trigger', triggerOver ? 'x-form-trigger-over' : '']
3036
+ .filter(Boolean)
3037
+ .join(' ')}
3038
+ src={BLANK_IMG}
3039
+ alt=""
3040
+ style={{ flex: '0 0 auto' }}
3041
+ onMouseEnter={() => setTriggerOver(true)}
3042
+ onMouseLeave={() => setTriggerOver(false)}
3043
+ onClick={() => !disabled && toggle()}
3044
+ />
3045
+ </span>
3046
+ {open && coords &&
3047
+ createPortal(
3048
+ <div
3049
+ className="x-menu x-date-menu x-menu-floating x-layer x-menu-plain"
3050
+ style={{ position: 'fixed', visibility: 'visible', zIndex: 15000, top: coords.top, left: coords.left }}
3051
+ >
3052
+ <div className="x-menu-body">
3053
+ <ul className="x-menu-list">
3054
+ <li className="x-menu-list-item">
3055
+ <DatePicker
3056
+ className="x-menu-date-item"
3057
+ value={selected}
3058
+ minDate={dynMin ?? null}
3059
+ maxDate={dynMax ?? null}
3060
+ disabledDays={dynDisabledDays}
3061
+ onSelect={(d) => {
3062
+ const f = formatDate(d, format);
3063
+ if (value === undefined) setInternal(f);
3064
+ setOpen(false);
3065
+ onChange?.(f);
3066
+ }}
3067
+ />
3068
+ </li>
3069
+ </ul>
3070
+ </div>
3071
+ </div>,
3072
+ getPortalRoot(),
3073
+ )}
3074
+ </span>
3075
+ );
3076
+ });
3077
+ DateField.displayName = 'DateField';
3078
+
3079
+ /* ------------------------------------------------------------------ */
3080
+ /* FormPanel —— 复刻 Ext.FormPanel(Panel + FormLayout) */
3081
+ /* ------------------------------------------------------------------ */
3082
+ export interface FormPanelProps {
3083
+ title?: string;
3084
+ width?: number;
3085
+ height?: number;
3086
+ labelAlign?: 'left' | 'top' | 'right';
3087
+ bodyPadding?: number;
3088
+ frame?: boolean;
3089
+ children?: ReactNode;
3090
+ }
3091
+ export function FormPanel({
3092
+ title,
3093
+ width,
3094
+ height,
3095
+ labelAlign = 'left',
3096
+ bodyPadding = 10,
3097
+ frame = true,
3098
+ children,
3099
+ }: FormPanelProps) {
3100
+ // 复用 Panel 容器,仅额外提供 x-form-label-* 容器类与 x-form-body。
3101
+ // labelWidth/labelPad 由 FormField 默认值(left/100/5,与 Ext 一致)承接。
3102
+ return (
3103
+ <Panel title={title} width={width} height={height} frame={frame} bodyPadding={bodyPadding}>
3104
+ <form className="x-form-body" style={{ padding: 0 }}>
3105
+ <div className={`x-form-label-${labelAlign}`}>{children}</div>
3106
+ </form>
3107
+ </Panel>
3108
+ );
3109
+ }
3110
+
3111
+ /* ------------------------------------------------------------------ */
3112
+ /* ItemSelector —— 复刻 Ext.ux.form.ItemSelector(双栏项选择器) */
3113
+ /* 内部即两个 MultiSelect(Available / Selected),中间一列 6 个图标 */
3114
+ /* 按钮:toTop/up/add(→)/remove(←)/down/toBottom。 */
3115
+ /* 选择逻辑对齐 ListView multiSelect:单击替换、Ctrl/Cmd 加/减选、 */
3116
+ /* Shift 范围选;move 操作在两栏间搬移,arrow 操作重排 Selected 栏。 */
3117
+ /* 原 Ext 用 gif 箭头图标(up2.gif 等),本项目无该资源,改用 Button */
3118
+ /* 内箭头字形(⇈ ↑ → ← ↓ ⇊)替代,属保真等效替换。 */
3119
+ /* ------------------------------------------------------------------ */
3120
+ export interface ItemSelectorProps {
3121
+ /** 全部可选项(可缺省,与 fromData/toData 二选一)。支持 string[] / [value,text][] / {value,text}[] */
3122
+ data?: Array<string | [string, string] | { value: string; text: string }>;
3123
+ /** 初始选中项的值集合(置于 Selected 栏)。可用逗号分隔串或数组 */
3124
+ value?: string | Array<string | number>;
3125
+ /** Available 栏数据(与 data/value 二选一) */
3126
+ fromData?: Array<string | [string, string] | { value: string; text: string }>;
3127
+ /** Selected 栏初始数据(与 data/value 二选一) */
3128
+ toData?: Array<string | [string, string] | { value: string; text: string }>;
3129
+ /** Available 栏标题(默认 Available) */
3130
+ fromLegend?: string;
3131
+ /** Selected 栏标题(默认 Selected) */
3132
+ toLegend?: string;
3133
+ fromName?: string;
3134
+ toName?: string;
3135
+ /** 每栏列表宽度(px,默认 180) */
3136
+ width?: number;
3137
+ /** 每栏列表高度(px,默认 180) */
3138
+ height?: number;
3139
+ /** getValue 分隔符(默认 ,) */
3140
+ delimiter?: string;
3141
+ /** 隐藏导航图标按钮(默认 false,全部显示) */
3142
+ hideNavIcons?: boolean;
3143
+ drawTopIcon?: boolean;
3144
+ drawUpIcon?: boolean;
3145
+ drawAddIcon?: boolean;
3146
+ drawRemoveIcon?: boolean;
3147
+ drawDownIcon?: boolean;
3148
+ drawBottomIcon?: boolean;
3149
+ disabled?: boolean;
3150
+ id?: string;
3151
+ cls?: string;
3152
+ style?: CSSProperties;
3153
+ /** 选中值变化时回调(回传 Selected 栏值数组) */
3154
+ onChange?: (value: string[]) => void;
3155
+ }
3156
+ export interface ItemSelectorHandle {
3157
+ getValue: () => string;
3158
+ getRawValue: () => string[];
3159
+ getSelected: () => Array<{ value: string; text: string }>;
3160
+ setValue: (v: string | Array<string | number>) => void;
3161
+ /** 将全部项移回 Available 栏 */
3162
+ reset: () => void;
3163
+ clear: () => void;
3164
+ add: () => void;
3165
+ remove: () => void;
3166
+ up: () => void;
3167
+ down: () => void;
3168
+ toTop: () => void;
3169
+ toBottom: () => void;
3170
+ disable: () => void;
3171
+ enable: () => void;
3172
+ focus: () => void;
3173
+ }
3174
+ type ISItem = { value: string; text: string };
3175
+
3176
+ function normalizeItems(
3177
+ raw?: Array<string | [string, string] | { value: string; text: string }>,
3178
+ ): ISItem[] {
3179
+ if (!raw) return [];
3180
+ return raw.map((r) => {
3181
+ if (typeof r === 'string') return { value: r, text: r };
3182
+ if (Array.isArray(r)) return { value: String(r[0]), text: String(r[1]) };
3183
+ return { value: String(r.value), text: String(r.text) };
3184
+ });
3185
+ }
3186
+ function toArr(v?: string | Array<string | number>): string[] {
3187
+ if (v == null) return [];
3188
+ if (typeof v === 'string') return v === '' ? [] : v.split(',').map((s) => s.trim());
3189
+ return v.map((x) => String(x));
3190
+ }
3191
+
3192
+ export const ItemSelector = forwardRef<ItemSelectorHandle, ItemSelectorProps>(
3193
+ function ItemSelector(props, ref) {
3194
+ const {
3195
+ data,
3196
+ value,
3197
+ fromData,
3198
+ toData,
3199
+ fromLegend = 'Available',
3200
+ toLegend = 'Selected',
3201
+ fromName,
3202
+ toName,
3203
+ width = 180,
3204
+ height = 180,
3205
+ delimiter = ',',
3206
+ hideNavIcons = false,
3207
+ drawTopIcon = true,
3208
+ drawUpIcon = true,
3209
+ drawAddIcon = true,
3210
+ drawRemoveIcon = true,
3211
+ drawDownIcon = true,
3212
+ drawBottomIcon = true,
3213
+ disabled = false,
3214
+ id,
3215
+ cls,
3216
+ style,
3217
+ onChange,
3218
+ } = props;
3219
+
3220
+ // 初始拆分:data+value 形态 → 全量 data 减去 value 为 Available,value 为 Selected
3221
+ const initial = useMemo<{ from: ISItem[]; to: ISItem[] }>(() => {
3222
+ if (fromData || toData) {
3223
+ return { from: normalizeItems(fromData), to: normalizeItems(toData) };
3224
+ }
3225
+ const all = normalizeItems(data);
3226
+ const sel = new Set(toArr(value));
3227
+ return {
3228
+ from: all.filter((it) => !sel.has(it.value)),
3229
+ to: all.filter((it) => sel.has(it.value)),
3230
+ };
3231
+ }, []); // 仅初始化一次
3232
+
3233
+ const [fromItems, setFromItems] = useState<ISItem[]>(initial.from);
3234
+ const [toItems, setToItems] = useState<ISItem[]>(initial.to);
3235
+ const [fromSel, setFromSel] = useState<Set<string>>(new Set());
3236
+ const [toSel, setToSel] = useState<Set<string>>(new Set());
3237
+ const [disabledState, setDisabledState] = useState(disabled);
3238
+ const fromAnchor = useRef<number | null>(null);
3239
+ const toAnchor = useRef<number | null>(null);
3240
+ // 同步镜像 toItems(每次变更即时更新),使 getValue 在 setValue 等命令式调用后
3241
+ // 立即可读最新值(对齐 Ext 同步 store 行为;React setState 本身异步)。
3242
+ const toItemsRef = useRef<ISItem[]>(initial.to);
3243
+ toItemsRef.current = toItems;
3244
+
3245
+ const fireChange = useCallback(
3246
+ (to: ISItem[]) => {
3247
+ onChange?.(to.map((it) => it.value));
3248
+ },
3249
+ [onChange],
3250
+ );
3251
+
3252
+ const onItemClick = useCallback(
3253
+ (side: 'from' | 'to', index: number, e: React.MouseEvent) => {
3254
+ if (disabledState) return;
3255
+ const items = side === 'from' ? fromItems : toItems;
3256
+ const setSel = side === 'from' ? setFromSel : setToSel;
3257
+ const anchor = side === 'from' ? fromAnchor : toAnchor;
3258
+ const value = items[index].value;
3259
+ setSel((prev) => {
3260
+ const next = new Set(prev);
3261
+ if (e.shiftKey && anchor.current != null) {
3262
+ const [a, b] = [anchor.current, index].sort((x, y) => x - y);
3263
+ for (let i = a; i <= b; i++) next.add(items[i].value);
3264
+ } else if (e.ctrlKey || e.metaKey) {
3265
+ if (next.has(value)) next.delete(value);
3266
+ else next.add(value);
3267
+ anchor.current = index;
3268
+ } else {
3269
+ next.clear();
3270
+ next.add(value);
3271
+ anchor.current = index;
3272
+ }
3273
+ return next;
3274
+ });
3275
+ },
3276
+ [disabledState, fromItems, toItems],
3277
+ );
3278
+
3279
+ const add = useCallback(() => {
3280
+ if (disabledState || fromSel.size === 0) return;
3281
+ const moved = fromItems.filter((it) => fromSel.has(it.value));
3282
+ const restFrom = fromItems.filter((it) => !fromSel.has(it.value));
3283
+ const nextTo = [...toItems, ...moved];
3284
+ setFromItems(restFrom);
3285
+ setToItems(nextTo);
3286
+ toItemsRef.current = nextTo;
3287
+ setToSel(new Set([...toSel, ...moved.map((m) => m.value)]));
3288
+ setFromSel(new Set());
3289
+ fireChange(nextTo);
3290
+ }, [disabledState, fromItems, toItems, fromSel, toSel, fireChange]);
3291
+
3292
+ const remove = useCallback(() => {
3293
+ if (disabledState || toSel.size === 0) return;
3294
+ const moved = toItems.filter((it) => toSel.has(it.value));
3295
+ const restTo = toItems.filter((it) => !toSel.has(it.value));
3296
+ const nextFrom = [...fromItems, ...moved];
3297
+ setToItems(restTo);
3298
+ toItemsRef.current = restTo;
3299
+ setFromItems(nextFrom);
3300
+ setFromSel(new Set([...fromSel, ...moved.map((m) => m.value)]));
3301
+ setToSel(new Set());
3302
+ fireChange(restTo);
3303
+ }, [disabledState, fromItems, toItems, fromSel, toSel, fireChange]);
3304
+
3305
+ const reorder = useCallback(
3306
+ (mode: 'up' | 'down' | 'top' | 'bottom') => {
3307
+ if (disabledState || toSel.size === 0) return;
3308
+ const arr = toItems;
3309
+ const selIdx = arr
3310
+ .map((it, i) => (toSel.has(it.value) ? i : -1))
3311
+ .filter((i) => i >= 0);
3312
+ if (selIdx.length === 0) return;
3313
+ const next = [...arr];
3314
+ if (mode === 'up') {
3315
+ for (const i of [...selIdx].sort((a, b) => a - b)) {
3316
+ if (i > 0) [next[i - 1], next[i]] = [next[i], next[i - 1]];
3317
+ }
3318
+ } else if (mode === 'down') {
3319
+ for (const i of [...selIdx].sort((a, b) => b - a)) {
3320
+ if (i < next.length - 1) [next[i], next[i + 1]] = [next[i + 1], next[i]];
3321
+ }
3322
+ } else if (mode === 'top') {
3323
+ const sel = next.filter((it) => toSel.has(it.value));
3324
+ const rest = next.filter((it) => !toSel.has(it.value));
3325
+ next.length = 0;
3326
+ next.push(...sel, ...rest);
3327
+ } else {
3328
+ const sel = next.filter((it) => toSel.has(it.value));
3329
+ const rest = next.filter((it) => !toSel.has(it.value));
3330
+ next.length = 0;
3331
+ next.push(...rest, ...sel);
3332
+ }
3333
+ setToItems(next);
3334
+ toItemsRef.current = next;
3335
+ },
3336
+ [disabledState, toItems, toSel],
3337
+ );
3338
+
3339
+ useImperativeHandle(ref, () => ({
3340
+ getValue: () => toItemsRef.current.map((it) => it.value).join(delimiter),
3341
+ getRawValue: () => toItemsRef.current.map((it) => it.value),
3342
+ getSelected: () => toItemsRef.current.map((it) => ({ ...it })),
3343
+ setValue: (v) => {
3344
+ const sel = new Set(toArr(v as string | Array<string | number>));
3345
+ const all = [...fromItems, ...toItems];
3346
+ const toNext = all.filter((it) => sel.has(it.value));
3347
+ const fromNext = all.filter((it) => !sel.has(it.value));
3348
+ setToItems(toNext);
3349
+ toItemsRef.current = toNext;
3350
+ setFromItems(fromNext);
3351
+ setFromSel(new Set());
3352
+ setToSel(new Set(sel));
3353
+ fireChange(toNext);
3354
+ },
3355
+ reset: () => {
3356
+ setFromItems((prevFrom) => [...prevFrom, ...toItems]);
3357
+ setToItems([]);
3358
+ toItemsRef.current = [];
3359
+ setFromSel(new Set());
3360
+ setToSel(new Set());
3361
+ fireChange([]);
3362
+ },
3363
+ clear: () => {
3364
+ setFromItems((prevFrom) => [...prevFrom, ...toItems]);
3365
+ setToItems([]);
3366
+ toItemsRef.current = [];
3367
+ setFromSel(new Set());
3368
+ setToSel(new Set());
3369
+ fireChange([]);
3370
+ },
3371
+ add,
3372
+ remove,
3373
+ up: () => reorder('up'),
3374
+ down: () => reorder('down'),
3375
+ toTop: () => reorder('top'),
3376
+ toBottom: () => reorder('bottom'),
3377
+ disable: () => setDisabledState(true),
3378
+ enable: () => setDisabledState(false),
3379
+ focus: () => {},
3380
+ }));
3381
+
3382
+ const showTop = drawTopIcon && !hideNavIcons;
3383
+ const showUp = drawUpIcon && !hideNavIcons;
3384
+ const showAdd = drawAddIcon && !hideNavIcons;
3385
+ const showRemove = drawRemoveIcon && !hideNavIcons;
3386
+ const showDown = drawDownIcon && !hideNavIcons;
3387
+ const showBottom = drawBottomIcon && !hideNavIcons;
3388
+
3389
+ const renderList = (side: 'from' | 'to') => {
3390
+ const items = side === 'from' ? fromItems : toItems;
3391
+ const sel = side === 'from' ? fromSel : toSel;
3392
+ const legend = side === 'from' ? fromLegend : toLegend;
3393
+ const name = side === 'from' ? fromName : toName;
3394
+ return (
3395
+ <fieldset className="x-fieldset x-multiselect" disabled={disabledState}>
3396
+ <legend className="x-fieldset-header">{legend}</legend>
3397
+ <div className="x-fieldset-bwrap">
3398
+ <div
3399
+ className="x-fieldset-body ux-mselect"
3400
+ style={{ width, height, ...(name ? { 'aria-label': name } : {}) }}
3401
+ onDoubleClick={(e) => {
3402
+ const t = e.target as HTMLElement;
3403
+ const node = t.closest('.ux-mselect-item') as HTMLElement | null;
3404
+ if (!node || !node.dataset.value) return;
3405
+ const v = node.dataset.value;
3406
+ if (side === 'from') {
3407
+ const moved = fromItems.filter((it) => it.value === v);
3408
+ setFromItems(fromItems.filter((it) => it.value !== v));
3409
+ const nextTo = [...toItems, ...moved];
3410
+ setToItems(nextTo);
3411
+ toItemsRef.current = nextTo;
3412
+ setToSel(new Set([...toSel, v]));
3413
+ setFromSel(new Set());
3414
+ fireChange(nextTo);
3415
+ } else {
3416
+ const moved = toItems.filter((it) => it.value === v);
3417
+ const nextTo = toItems.filter((it) => it.value !== v);
3418
+ setToItems(nextTo);
3419
+ toItemsRef.current = nextTo;
3420
+ setFromItems([...fromItems, ...moved]);
3421
+ setFromSel(new Set([...fromSel, v]));
3422
+ setToSel(new Set());
3423
+ fireChange(nextTo);
3424
+ }
3425
+ }}
3426
+ >
3427
+ {items.map((it, i) => {
3428
+ const isSel = sel.has(it.value);
3429
+ return (
3430
+ <div
3431
+ key={it.value}
3432
+ data-value={it.value}
3433
+ className={['ux-mselect-item', isSel ? 'ux-mselect-selected' : '']
3434
+ .filter(Boolean)
3435
+ .join(' ')}
3436
+ title={it.text}
3437
+ onClick={(e) => onItemClick(side, i, e)}
3438
+ >
3439
+ {it.text}
3440
+ </div>
3441
+ );
3442
+ })}
3443
+ </div>
3444
+ </div>
3445
+ </fieldset>
3446
+ );
3447
+ };
3448
+
3449
+ return (
3450
+ <span
3451
+ id={id}
3452
+ className={['x-itemselector', disabledState ? 'x-item-disabled' : '', cls]
3453
+ .filter(Boolean)
3454
+ .join(' ')}
3455
+ style={style}
3456
+ >
3457
+ {renderList('from')}
3458
+ <span className="x-itemselector-actions">
3459
+ {showTop && (
3460
+ <Button scale="small" text="⇈" onClick={() => reorder('top')} disabled={disabledState} />
3461
+ )}
3462
+ {showUp && (
3463
+ <Button scale="small" text="↑" onClick={() => reorder('up')} disabled={disabledState} />
3464
+ )}
3465
+ {showAdd && (
3466
+ <Button scale="small" text="→" onClick={add} disabled={disabledState} />
3467
+ )}
3468
+ {showRemove && (
3469
+ <Button scale="small" text="←" onClick={remove} disabled={disabledState} />
3470
+ )}
3471
+ {showDown && (
3472
+ <Button scale="small" text="↓" onClick={() => reorder('down')} disabled={disabledState} />
3473
+ )}
3474
+ {showBottom && (
3475
+ <Button scale="small" text="⇊" onClick={() => reorder('bottom')} disabled={disabledState} />
3476
+ )}
3477
+ </span>
3478
+ {renderList('to')}
3479
+ <input type="hidden" name={toName || fromName} value={toItems.map((it) => it.value).join(delimiter)} />
3480
+ </span>
3481
+ );
3482
+ },
3483
+ );
3484
+ ItemSelector.displayName = 'ItemSelector';