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,353 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useImperativeHandle,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type KeyboardEvent as ReactKeyboardEvent,
10
+ type MouseEvent as ReactMouseEvent,
11
+ } from 'react';
12
+ import { createPortal } from 'react-dom';
13
+
14
+ export interface SliderProps {
15
+ value?: number;
16
+ defaultValue?: number;
17
+ minValue?: number;
18
+ maxValue?: number;
19
+ /** 吸附步长,0 表示不吸附(默认 0)。 */
20
+ increment?: number;
21
+ /** 键盘方向键每次步进(默认 1)。 */
22
+ keyIncrement?: number;
23
+ decimalPrecision?: number;
24
+ vertical?: boolean;
25
+ /** 水平滑块宽度 / 垂直滑块高度(对应 Ext 配置项的 content 尺寸)。 */
26
+ width?: number;
27
+ height?: number;
28
+ clickToChange?: boolean;
29
+ disabled?: boolean;
30
+ cls?: string;
31
+ style?: CSSProperties;
32
+ onChange?: (value: number) => void;
33
+ onChangeComplete?: (value: number) => void;
34
+ /** 可选 tip:提供此函数时,拖动滑块会浮出跟随 thumb 的提示气泡(对齐 Ext.slider.Tip)。
35
+ * 参数为 (value, maxValue),返回 HTML 字符串(内部用 dangerouslySetInnerHTML 渲染,如 'Page <b>1</b> of <b>25</b>')。 */
36
+ getTipText?: (value: number, maxValue: number) => string;
37
+ }
38
+
39
+ export interface SliderHandle {
40
+ getValue: () => number;
41
+ setValue: (v: number, animate?: boolean) => void;
42
+ }
43
+
44
+ const HALF_THUMB = 7;
45
+
46
+ /**
47
+ * 复刻 Ext.slider.SingleSlider(Ext 3.2.1)。
48
+ * DOM:.x-slider.{x-slider-horz|x-slider-vert} > .x-slider-end > .x-slider-inner > (.x-slider-focus + .x-slider-thumb)
49
+ * 定位公式对齐 Ext:
50
+ * ratio = innerSize / (maxValue - minValue)
51
+ * horizontal: thumb.left = (value - min) * ratio - HALF_THUMB
52
+ * vertical: thumb.bottom = (value - min) * ratio - HALF_THUMB
53
+ * 关键:Ext 在 onResize 里把 inner 显式设为 dim - 14(两端各 7px padding),
54
+ * 故 React 同样给 inner 设 width/height = dim - 14,否则 ratio 偏差 7px 破坏像素保真。
55
+ */
56
+ export const Slider = forwardRef<SliderHandle, SliderProps>(function Slider(
57
+ {
58
+ value,
59
+ defaultValue = 0,
60
+ minValue = 0,
61
+ maxValue = 100,
62
+ increment = 0,
63
+ keyIncrement = 1,
64
+ decimalPrecision = 0,
65
+ vertical = false,
66
+ width,
67
+ height,
68
+ clickToChange = true,
69
+ disabled = false,
70
+ cls = '',
71
+ style,
72
+ onChange,
73
+ onChangeComplete,
74
+ getTipText,
75
+ },
76
+ ref,
77
+ ) {
78
+ const rootRef = useRef<HTMLDivElement>(null);
79
+ const innerRef = useRef<HTMLDivElement>(null);
80
+ const thumbRef = useRef<HTMLDivElement>(null);
81
+ const dragRef = useRef(false);
82
+ const latestValueRef = useRef(defaultValue);
83
+
84
+ const isControlled = value !== undefined;
85
+ const [internal, setInternal] = useState(defaultValue);
86
+ // 拖动期间用本地 dragValue 驱动渲染,使受控模式下 thumb 也能实时跟随鼠标(对齐 Ext 非受控滑块)。
87
+ const [dragValue, setDragValue] = useState<number | null>(null);
88
+ const current = dragValue !== null ? dragValue : isControlled ? (value as number) : internal;
89
+
90
+ const [innerSize, setInnerSize] = useState(0);
91
+ const [hover, setHover] = useState(false);
92
+ const [dragging, setDragging] = useState(false);
93
+ // 拖动 tip(对齐 Ext.slider.Tip:dragstart 显示、drag 更新、dragend 隐藏)。
94
+ const [tipText, setTipText] = useState('');
95
+ const [tipPos, setTipPos] = useState<{ left: number; top: number } | null>(null);
96
+
97
+ // 测量 inner 实际尺寸(受显式 width/height = dim-14 影响)。
98
+ useLayoutEffect(() => {
99
+ const el = innerRef.current;
100
+ if (!el) return;
101
+ const measure = () =>
102
+ setInnerSize(vertical ? el.offsetHeight : el.offsetWidth);
103
+ measure();
104
+ const ro = new ResizeObserver(measure);
105
+ ro.observe(el);
106
+ return () => ro.disconnect();
107
+ }, [vertical]);
108
+
109
+ const ratio = innerSize > 0 ? innerSize / (maxValue - minValue || 1) : 0;
110
+
111
+ const normalize = useCallback(
112
+ (v: number) => {
113
+ let nv = v;
114
+ if (increment > 0) {
115
+ nv = Math.round((nv - minValue) / increment) * increment + minValue;
116
+ }
117
+ const p = Math.pow(10, decimalPrecision);
118
+ nv = Math.round(nv * p) / p;
119
+ if (nv < minValue) nv = minValue;
120
+ if (nv > maxValue) nv = maxValue;
121
+ return nv;
122
+ },
123
+ [increment, minValue, maxValue, decimalPrecision],
124
+ );
125
+
126
+ const setValue = useCallback(
127
+ (v: number, fireChange = true) => {
128
+ const nv = normalize(v);
129
+ latestValueRef.current = nv;
130
+ if (!isControlled) setInternal(nv);
131
+ if (fireChange) onChange?.(nv);
132
+ },
133
+ [normalize, isControlled, onChange],
134
+ );
135
+
136
+ useImperativeHandle(
137
+ ref,
138
+ () => ({
139
+ getValue: () => latestValueRef.current,
140
+ setValue: (v: number) => setValue(v),
141
+ }),
142
+ [setValue],
143
+ );
144
+
145
+ const thumbPos = ratio > 0 ? (current - minValue) * ratio - HALF_THUMB : 0;
146
+
147
+ const onKeyDown = useCallback(
148
+ (e: ReactKeyboardEvent) => {
149
+ if (disabled) return;
150
+ let v: number | null = null;
151
+ switch (e.key) {
152
+ case 'ArrowUp':
153
+ case 'ArrowRight':
154
+ e.preventDefault();
155
+ v = e.ctrlKey ? maxValue : current + keyIncrement;
156
+ break;
157
+ case 'ArrowDown':
158
+ case 'ArrowLeft':
159
+ e.preventDefault();
160
+ v = e.ctrlKey ? minValue : current - keyIncrement;
161
+ break;
162
+ case 'Home':
163
+ e.preventDefault();
164
+ v = minValue;
165
+ break;
166
+ case 'End':
167
+ e.preventDefault();
168
+ v = maxValue;
169
+ break;
170
+ case 'PageUp':
171
+ e.preventDefault();
172
+ v = current + keyIncrement * 10;
173
+ break;
174
+ case 'PageDown':
175
+ e.preventDefault();
176
+ v = current - keyIncrement * 10;
177
+ break;
178
+ default:
179
+ return;
180
+ }
181
+ setValue(v);
182
+ onChangeComplete?.(normalize(v));
183
+ },
184
+ [disabled, current, keyIncrement, maxValue, minValue, setValue, onChangeComplete, normalize],
185
+ );
186
+
187
+ const posFromEvent = useCallback(
188
+ (clientX: number, clientY: number) => {
189
+ const rect = innerRef.current!.getBoundingClientRect();
190
+ return vertical ? rect.height - (clientY - rect.top) : clientX - rect.left;
191
+ },
192
+ [vertical],
193
+ );
194
+
195
+ const onMouseDown = useCallback(
196
+ (e: ReactMouseEvent) => {
197
+ if (disabled || ratio === 0) return;
198
+ const target = e.target as HTMLElement;
199
+ const onThumb = thumbRef.current != null && thumbRef.current.contains(target);
200
+
201
+ // 拖拽 thumb:thumb 中心跟随鼠标(对齐 Ext DragTracker 行为)。
202
+ if (onThumb) {
203
+ e.preventDefault();
204
+ dragRef.current = true;
205
+ setDragging(true);
206
+ // 跟随 thumb 的 tip:dragstart 立即显示并更新位置/文案。
207
+ const updateTip = () => {
208
+ if (!getTipText || !thumbRef.current) return;
209
+ const r = thumbRef.current.getBoundingClientRect();
210
+ setTipText(getTipText(latestValueRef.current, maxValue));
211
+ setTipPos({ left: r.left + r.width / 2, top: r.top - 10 });
212
+ };
213
+ updateTip();
214
+ const move = (ev: MouseEvent) => {
215
+ if (!dragRef.current) return;
216
+ const pos = posFromEvent(ev.clientX, ev.clientY);
217
+ const nv = normalize(minValue + pos / ratio);
218
+ // 受控模式下实时更新渲染与 tip,并持续提交 onChange(对齐 Ext 滑块拖动实时变更),
219
+ // 松手再提交 onChangeComplete。
220
+ setDragValue(nv);
221
+ latestValueRef.current = nv;
222
+ onChange?.(nv);
223
+ updateTip();
224
+ };
225
+ const up = () => {
226
+ dragRef.current = false;
227
+ setDragging(false);
228
+ // 松手提交最终值:非受控模式下把值固化到 internal(对齐 Ext 拖动结束 setValue 提交),
229
+ // 否则 setDragValue(null) 后 current 回落到 stale 的 internal,thumb 回弹。
230
+ // fireChange=false:拖动过程已在 move 中持续 onChange,避免重复触发;onChangeComplete 单独上报。
231
+ setValue(latestValueRef.current, false);
232
+ setDragValue(null);
233
+ setTipPos(null);
234
+ window.removeEventListener('mousemove', move);
235
+ window.removeEventListener('mouseup', up);
236
+ onChangeComplete?.(latestValueRef.current);
237
+ };
238
+ window.addEventListener('mousemove', move);
239
+ window.addEventListener('mouseup', up);
240
+ return;
241
+ }
242
+
243
+ // 点击轨道跳转(clickToChange)。
244
+ if (clickToChange) {
245
+ const pos = posFromEvent(e.clientX, e.clientY);
246
+ const nv = normalize(minValue + pos / ratio);
247
+ setValue(nv);
248
+ onChangeComplete?.(nv);
249
+ }
250
+ },
251
+ [disabled, ratio, clickToChange, posFromEvent, setValue, minValue, onChangeComplete, normalize],
252
+ );
253
+
254
+ const rootClass = [
255
+ 'x-slider',
256
+ vertical ? 'x-slider-vert' : 'x-slider-horz',
257
+ disabled ? 'x-slider-disabled' : '',
258
+ cls,
259
+ ]
260
+ .filter(Boolean)
261
+ .join(' ');
262
+
263
+ const dim = vertical ? height : width;
264
+ const innerDim = dim != null ? dim - 14 : undefined;
265
+ const rootStyle: CSSProperties = {
266
+ // Ext 渲染 Slider 时 width/height 配置语义为 border-box(含两端的 7px padding),
267
+ // 不设置则整元素会多出 7px padding,且与 golden 截图尺寸差 7px 破坏像素保真。
268
+ boxSizing: 'border-box',
269
+ ...(vertical
270
+ ? height != null
271
+ ? { height }
272
+ : {}
273
+ : width != null
274
+ ? { width }
275
+ : {}),
276
+ ...style,
277
+ };
278
+ const innerStyle: CSSProperties = vertical
279
+ ? innerDim != null
280
+ ? { height: innerDim }
281
+ : {}
282
+ : innerDim != null
283
+ ? { width: innerDim }
284
+ : {};
285
+
286
+ const thumbStyle: CSSProperties = vertical
287
+ ? { bottom: `${thumbPos}px` }
288
+ : { left: `${thumbPos}px` };
289
+ const thumbClass = [
290
+ 'x-slider-thumb',
291
+ hover ? 'x-slider-thumb-over' : '',
292
+ dragging ? 'x-slider-thumb-drag' : '',
293
+ ]
294
+ .filter(Boolean)
295
+ .join(' ');
296
+
297
+ return (
298
+ <>
299
+ <div
300
+ ref={rootRef}
301
+ className={rootClass}
302
+ style={rootStyle}
303
+ data-value={current}
304
+ tabIndex={disabled ? -1 : 0}
305
+ onMouseDown={onMouseDown}
306
+ onKeyDown={onKeyDown}
307
+ >
308
+ <div className="x-slider-end">
309
+ <div className="x-slider-inner" ref={innerRef} style={innerStyle}>
310
+ <a className="x-slider-focus" href="#" tabIndex={-1} />
311
+ <div
312
+ ref={thumbRef}
313
+ className={thumbClass}
314
+ style={thumbStyle}
315
+ onMouseEnter={() => setHover(true)}
316
+ onMouseLeave={() => setHover(false)}
317
+ />
318
+ </div>
319
+ </div>
320
+ </div>
321
+ {/* Slider Tip:复用原始 Ext 3.2.1 .x-tip 结构类(ext-all.css),portal 到 body 避免被 toolbar 裁剪。 */}
322
+ {tipPos && getTipText && createPortal(
323
+ <div
324
+ className="x-tip"
325
+ style={{
326
+ position: 'fixed',
327
+ left: tipPos.left,
328
+ top: tipPos.top,
329
+ // 对齐 Ext.slider.Tip:alignTo(thumb.el,'b-t?',[0,-10]) —— tip 底边贴 thumb 顶边、
330
+ // 上移 10px 且水平居中。translate(-50%,-100%) 使 tip 中心对准 thumb 中心、整体位于其上方,
331
+ // 箭头(x-tip-anchor-bottom)自然指向下方的 thumb。
332
+ transform: 'translate(-50%, -100%)',
333
+ visibility: 'visible',
334
+ zIndex: 20001,
335
+ }}
336
+ >
337
+ <div className="x-tip-tl"><div className="x-tip-tr"><div className="x-tip-tc" /></div></div>
338
+ <div className="x-tip-bwrap">
339
+ <div className="x-tip-ml"><div className="x-tip-mr"><div className="x-tip-mc">
340
+ <div className="x-tip-body" dangerouslySetInnerHTML={{ __html: tipText }} />
341
+ </div></div></div>
342
+ </div>
343
+ <div className="x-tip-bl"><div className="x-tip-br"><div className="x-tip-bc" /></div></div>
344
+ <div
345
+ className="x-tip-anchor x-tip-anchor-bottom"
346
+ style={{ left: '50%', marginLeft: -4, bottom: -9 }}
347
+ />
348
+ </div>,
349
+ document.body,
350
+ )}
351
+ </>
352
+ );
353
+ });
@@ -0,0 +1,206 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useImperativeHandle,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ } from 'react';
10
+ import { Slider, type SliderHandle } from './Slider';
11
+
12
+ /**
13
+ * 保真 Ext.form.SliderField(Ext JS 3.2.1)。
14
+ *
15
+ * 来源 src/widgets/form/SliderField.js。把 Ext.Slider 封装为表单字段:
16
+ * 渲染为 div.x-form-field-wrap(actionMode:'wrap' 时 FormLayout 即包裹此元素),
17
+ * 内含 input[type=hidden](承载字段值,对齐 this.el)与 Slider 本体(对齐 this.slider)。
18
+ *
19
+ * 与原版逐项对齐:
20
+ * - initComponent 把 vertical/minValue/maxValue/decimalPrecision/keyIncrement/increment/
21
+ * clickToChange 透传给内部 Slider;useTips(默认 true) 启用滑块 tip,tipText 自定义文案。
22
+ * - onChange(slider 'change')→ setValue(v, undefined, true)(silent),即只更新字段隐藏值、
23
+ * 不再回写 slider(避免回环);这里等价:SliderField 持有值并单向驱动 Slider,Slider 的
24
+ * change 回调再回填 SliderField 的值,不回写 Slider 本身。
25
+ * - getValue → slider.getValue();setValue(v, animate) → 先写 slider 再回填隐藏值。
26
+ * - setMinValue/setMaxValue/disable/enable 全部委托内部 Slider(对齐原版同名方法)。
27
+ *
28
+ * 注意:本复刻以受控/非受控 value 作为字段真值(hidden input 仅用于提交与 DOM 保真),
29
+ * 组件本身即为「字段」,配合 FormField(标签包裹)或独立使用,与 SpinnerField 一致。
30
+ */
31
+ export interface SliderFieldProps {
32
+ /** 字段 id(落到 hidden input 与 wrap 上,供 label htmlFor 关联)。 */
33
+ id?: string;
34
+ /** 表单提交字段名(落到 hidden input.name)。 */
35
+ name?: string;
36
+ /** 受控值(数字)。提供时作为唯一来源(受控模式)。 */
37
+ value?: number;
38
+ /** 非受控初始值,默认 0。 */
39
+ defaultValue?: number;
40
+ minValue?: number;
41
+ maxValue?: number;
42
+ /** 吸附步长,0 表示不吸附(默认 0)。 */
43
+ increment?: number;
44
+ keyIncrement?: number;
45
+ decimalPrecision?: number;
46
+ vertical?: boolean;
47
+ /** 水平滑块宽度 / 垂直滑块高度(直接透传给内部 Slider)。 */
48
+ width?: number;
49
+ height?: number;
50
+ clickToChange?: boolean;
51
+ disabled?: boolean;
52
+ /** 对齐 Ext useTips:是否使用滑过提示气泡,默认 true。 */
53
+ useTips?: boolean;
54
+ /** 自定义 tip 文案函数(对齐 Ext tipText),参数为当前值,返回字符串;为空用默认 String(value)。 */
55
+ tipText?: (value: number) => string;
56
+ cls?: string;
57
+ style?: CSSProperties;
58
+ /** 值变化回调(拖动/点击/键盘过程中持续触发,对齐 Ext 'change')。 */
59
+ onChange?: (value: number) => void;
60
+ /** 交互结束(松手/键盘完成)回调(对齐 Ext 'changecomplete')。 */
61
+ onChangeComplete?: (value: number) => void;
62
+ }
63
+
64
+ /** 命令式句柄:对齐 Ext.form.SliderField 的 getValue / setValue / setMinValue / setMaxValue / focus。 */
65
+ export interface SliderFieldHandle {
66
+ getValue: () => number;
67
+ setValue: (v: number, animate?: boolean) => void;
68
+ setMinValue: (v: number) => void;
69
+ setMaxValue: (v: number) => void;
70
+ focus: () => void;
71
+ }
72
+
73
+ export const SliderField = forwardRef<SliderFieldHandle, SliderFieldProps>(function SliderField(
74
+ {
75
+ id,
76
+ name,
77
+ value,
78
+ defaultValue = 0,
79
+ minValue = 0,
80
+ maxValue = 100,
81
+ increment = 0,
82
+ keyIncrement = 1,
83
+ decimalPrecision = 0,
84
+ vertical = false,
85
+ width,
86
+ height,
87
+ clickToChange = true,
88
+ disabled = false,
89
+ useTips = true,
90
+ tipText,
91
+ cls = '',
92
+ style,
93
+ onChange,
94
+ onChangeComplete,
95
+ },
96
+ ref,
97
+ ) {
98
+ const isControlled = value !== undefined;
99
+ const [internal, setInternal] = useState(defaultValue);
100
+ const current = isControlled ? (value as number) : internal;
101
+
102
+ // 持有 min/max 于内部状态,使 setMinValue/setMaxValue 句柄能运行时修改(对齐 Ext 同名方法)。
103
+ const [min, setMin] = useState(minValue);
104
+ const [max, setMax] = useState(maxValue);
105
+
106
+ const sliderRef = useRef<SliderHandle>(null);
107
+ const hiddenRef = useRef<HTMLInputElement>(null);
108
+
109
+ // 同步隐藏字段值(对齐 Ext setValue → superclass.setValue 写 this.el)。
110
+ const syncHidden = useCallback((v: number) => {
111
+ if (hiddenRef.current) hiddenRef.current.value = String(v);
112
+ }, []);
113
+
114
+ const onSliderChange = useCallback(
115
+ (v: number) => {
116
+ if (!isControlled) setInternal(v);
117
+ syncHidden(v);
118
+ onChange?.(v);
119
+ },
120
+ [isControlled, syncHidden, onChange],
121
+ );
122
+
123
+ const onSliderChangeComplete = useCallback(
124
+ (v: number) => {
125
+ onChangeComplete?.(v);
126
+ },
127
+ [onChangeComplete],
128
+ );
129
+
130
+ // 对齐 useTips + tipText:提供 getTipText 给内部 Slider 即启用 tip。
131
+ const getTipText = useMemo(() => {
132
+ if (!useTips) return undefined;
133
+ return tipText ? (v: number) => tipText(v) : (v: number) => String(v);
134
+ }, [useTips, tipText]);
135
+
136
+ useImperativeHandle(
137
+ ref,
138
+ () => ({
139
+ getValue: () => (sliderRef.current ? sliderRef.current.getValue() : current),
140
+ setValue: (v: number, animate?: boolean) => {
141
+ if (!isControlled) setInternal(v);
142
+ syncHidden(v);
143
+ sliderRef.current?.setValue(v, animate);
144
+ onChange?.(v);
145
+ },
146
+ setMinValue: (v: number) => {
147
+ setMin(v);
148
+ // 边界上移后钳制当前值(对齐 Ext setMinValue 的 thumbs[i].value 修正)。
149
+ if (current < v) {
150
+ if (!isControlled) setInternal(v);
151
+ syncHidden(v);
152
+ sliderRef.current?.setValue(v);
153
+ onChange?.(v);
154
+ }
155
+ },
156
+ setMaxValue: (v: number) => {
157
+ setMax(v);
158
+ if (current > v) {
159
+ if (!isControlled) setInternal(v);
160
+ syncHidden(v);
161
+ sliderRef.current?.setValue(v);
162
+ onChange?.(v);
163
+ }
164
+ },
165
+ focus: () => {
166
+ const el = hiddenRef.current?.parentElement?.querySelector<HTMLElement>('.x-slider');
167
+ el?.focus();
168
+ },
169
+ }),
170
+ [current, isControlled, syncHidden, onChange],
171
+ );
172
+
173
+ const wrapClass = ['x-form-field-wrap', cls].filter(Boolean).join(' ');
174
+
175
+ return (
176
+ <div className={wrapClass} id={id ? `wrap-${id}` : undefined} style={style}>
177
+ <input
178
+ ref={hiddenRef}
179
+ type="hidden"
180
+ id={id}
181
+ name={name}
182
+ className="x-form-field"
183
+ value={String(current)}
184
+ />
185
+ <Slider
186
+ ref={sliderRef}
187
+ value={current}
188
+ minValue={min}
189
+ maxValue={max}
190
+ increment={increment}
191
+ keyIncrement={keyIncrement}
192
+ decimalPrecision={decimalPrecision}
193
+ vertical={vertical}
194
+ width={width}
195
+ height={height}
196
+ clickToChange={clickToChange}
197
+ disabled={disabled}
198
+ getTipText={getTipText}
199
+ onChange={onSliderChange}
200
+ onChangeComplete={onSliderChangeComplete}
201
+ />
202
+ </div>
203
+ );
204
+ });
205
+
206
+ SliderField.displayName = 'SliderField';
@@ -0,0 +1,141 @@
1
+ import React, { useRef, useState } from 'react';
2
+
3
+ const BLANK_GIF =
4
+ 'data:image/gif;base64,R0lGODlhAQABAID/AMDAwAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
5
+
6
+ // 字段(wrap)总宽 = Ext 的 width 配置。border-box 下输入框 width 即外宽,
7
+ // 故 input 外宽 = wrap 宽 - trigger 宽度(17);trigger 以绝对定位钉在右边缘,
8
+ // 中间 splitter 用 right:2 居中于 trigger。golden 实测 wrap=194 / input=177 吻合。
9
+ const TRIGGER_OFFSET = 17;
10
+
11
+ export interface SpinnerFieldProps {
12
+ value?: number | string;
13
+ minValue?: number;
14
+ maxValue?: number;
15
+ incrementValue?: number;
16
+ defaultValue?: number | string;
17
+ /** 字段(wrap)总宽,等同 Ext 的 width 配置。默认 194。 */
18
+ width?: number;
19
+ id?: string;
20
+ name?: string;
21
+ testId?: string;
22
+ className?: string;
23
+ style?: React.CSSProperties;
24
+ }
25
+
26
+ function clamp(v: number, min?: number, max?: number): number {
27
+ if (typeof min === 'number') v = Math.max(min, v);
28
+ if (typeof max === 'number') v = Math.min(max, v);
29
+ return v;
30
+ }
31
+
32
+ export const SpinnerField: React.FC<SpinnerFieldProps> = ({
33
+ value,
34
+ minValue,
35
+ maxValue,
36
+ incrementValue = 1,
37
+ defaultValue,
38
+ width = 194,
39
+ id,
40
+ name,
41
+ testId,
42
+ className,
43
+ style,
44
+ }) => {
45
+ const initial =
46
+ value !== undefined ? Number(value) : defaultValue !== undefined ? Number(defaultValue) : 0;
47
+ const [val, setVal] = useState<number>(clamp(initial, minValue, maxValue));
48
+ const triggerRef = useRef<HTMLImageElement>(null);
49
+ const dragRef = useRef<{ up: boolean; btn: number } | null>(null);
50
+
51
+ const wrapWidth = width;
52
+ const inputWidth = wrapWidth - TRIGGER_OFFSET;
53
+
54
+ const applyClickClass = (up: boolean) => {
55
+ const el = triggerRef.current;
56
+ if (!el) return;
57
+ el.classList.remove('x-form-spinner-clickup', 'x-form-spinner-clickdown');
58
+ el.classList.add(up ? 'x-form-spinner-clickup' : 'x-form-spinner-clickdown');
59
+ };
60
+
61
+ const clearClickClass = () => {
62
+ const el = triggerRef.current;
63
+ if (el) el.classList.remove('x-form-spinner-clickup', 'x-form-spinner-clickdown');
64
+ dragRef.current = null;
65
+ };
66
+
67
+ const spin = (e: React.MouseEvent) => {
68
+ const el = e.currentTarget as HTMLElement;
69
+ const rect = el.getBoundingClientRect();
70
+ const middle = rect.top + rect.height / 2;
71
+ const up = e.clientY < middle;
72
+ applyClickClass(up);
73
+ const next = clamp(val + (up ? 1 : -1) * incrementValue, minValue, maxValue);
74
+ setVal(next);
75
+ dragRef.current = { up, btn: e.button };
76
+ };
77
+
78
+ const onDown = (e: React.MouseEvent) => {
79
+ spin(e);
80
+ const move = () => {};
81
+ const up = () => {
82
+ clearClickClass();
83
+ window.removeEventListener('mouseup', up);
84
+ };
85
+ window.addEventListener('mouseup', up);
86
+ void move;
87
+ };
88
+
89
+ const rootCls = ['x-form-field-wrap', 'x-form-field-trigger-wrap', className]
90
+ .filter(Boolean)
91
+ .join(' ');
92
+
93
+ return (
94
+ <div
95
+ className={rootCls}
96
+ data-testid={testId}
97
+ id={id}
98
+ style={{ width: wrapWidth, ...style }}
99
+ >
100
+ <input
101
+ type="text"
102
+ autoComplete="off"
103
+ name={name}
104
+ className="x-form-text x-form-field x-form-num-field"
105
+ style={{ width: inputWidth }}
106
+ value={val}
107
+ readOnly
108
+ onChange={() => {}}
109
+ />
110
+ <img
111
+ ref={triggerRef}
112
+ src={BLANK_GIF}
113
+ className="x-form-trigger x-form-spinner-trigger x-unselectable"
114
+ onMouseDown={onDown}
115
+ />
116
+ <div
117
+ className="x-form-spinner-splitter"
118
+ style={{
119
+ width: '13px',
120
+ height: '2px',
121
+ right: '2px',
122
+ top: '10px',
123
+ visibility: 'visible',
124
+ }}
125
+ onMouseDown={onDown}
126
+ >
127
+ <div
128
+ className=" x-form-spinner-proxy"
129
+ style={{
130
+ width: '14px',
131
+ height: '1px',
132
+ position: 'relative',
133
+ left: '0px',
134
+ top: '-10px',
135
+ visibility: 'hidden',
136
+ }}
137
+ />
138
+ </div>
139
+ </div>
140
+ );
141
+ };