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,540 @@
1
+ import React, {
2
+ forwardRef,
3
+ useCallback,
4
+ useImperativeHandle,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+ import { createPortal } from 'react-dom';
10
+ import { Menu, type MenuItem } from './Menu';
11
+
12
+ /**
13
+ * TabPanel —— 保真复刻 Ext.TabPanel(src/widgets/TabPanel.js)。
14
+ *
15
+ * 保真契约(DOM/class 严格对齐源码):
16
+ * - 外层:x-tab-panel(baseCls='x-tab-panel')
17
+ * - 头部:x-panel-header x-tab-panel-header [x-tab-panel-header-plain]
18
+ * └ x-tab-strip-wrap > ul.x-tab-strip.x-tab-strip-{top|bottom}
19
+ * ├ li(每个 tab)
20
+ * │ cls: [x-tab-strip-closable][x-item-disabled][x-tab-with-icon] [tabCls]
21
+ * │ 激活: x-tab-strip-active hover: x-tab-strip-over
22
+ * │ <a class="x-tab-strip-close">(可关闭时)
23
+ * │ <a class="x-tab-right" href="#"><em class="x-tab-left">
24
+ * │ <span class="x-tab-strip-inner">
25
+ * │ <span class="x-tab-strip-text {iconCls}">{title}</span>
26
+ * │ </span></em></a>
27
+ * ├ li.x-tab-edge > span.x-tab-strip-text (&#160;)
28
+ * └ div.x-clear
29
+ * └ div.x-tab-strip-spacer
30
+ * - 主体:x-panel-body x-tab-panel-body x-tab-panel-body-{top|bottom}
31
+ * (非激活 tab 内容用 x-hide-display 隐藏,等价 Ext CardLayout)
32
+ *
33
+ * 交互(行为契约):
34
+ * - 点击 tab → setActiveTab:旧 li 去 x-tab-strip-active,新 li 加(源码 setActiveTab 814/820)
35
+ * - hover → 切换 x-tab-strip-over(仅未禁用;源码 initTab 520)
36
+ * - 可关闭:点击 x-tab-strip-close → beforeclose/close → 移除该 tab(源码 onStripMouseDown 466)
37
+ * - 禁用 tab 不可激活(源码 findTargets 444)
38
+ * - 键盘:←/→ 切换激活、Enter/Space 激活(现代化升级,等价于 Ext 把键盘 click 路由到 onStripMouseDown)
39
+ * - onBeforeTabChange 返回 false 可取消切换(源码 setActiveTab 803)
40
+ * - 动态增删:add(item,id?) / remove(key) / removeAt(index) / activate(key) 对齐 Ext
41
+ * TabPanel.add/remove 容器 API(items prop 恒为静态项来源 + 独立 dynamic 状态,双轨,保真零回归)。
42
+ */
43
+ export interface TabItem {
44
+ id?: string;
45
+ title: string;
46
+ iconCls?: string;
47
+ closable?: boolean;
48
+ disabled?: boolean;
49
+ tabCls?: string;
50
+ content: React.ReactNode;
51
+ }
52
+
53
+ export interface TabPanelProps {
54
+ items: TabItem[];
55
+ activeTab?: number;
56
+ tabPosition?: 'top' | 'bottom';
57
+ plain?: boolean;
58
+ border?: boolean;
59
+ /** 启用 tab 溢出滚动(对齐 Ext.TabPanel.enableTabScroll,默认 false)。tabs 总宽超过容器时渲染左右滚动箭头。 */
60
+ enableTabScroll?: boolean;
61
+ /** TabCloseMenu 插件:右键 tab 弹出关闭菜单(关闭/关闭其他/关闭全部),尊重 closable。 */
62
+ closeMenu?: boolean;
63
+ /** TabScrollerMenu 插件:enableTabScroll 且出现滚动条时,滚动箭头旁出现下拉菜单按钮,列出全部 tab 跳转。 */
64
+ scrollerMenu?: boolean;
65
+ width?: number | string;
66
+ height?: number | string;
67
+ onTabChange?: (index: number) => void;
68
+ onBeforeTabChange?: (index: number) => boolean;
69
+ onClose?: (index: number) => void;
70
+ }
71
+
72
+ export interface TabPanelHandle {
73
+ /** 追加一个 tab,返回其稳定 key(缺省时自动生成)。默认不激活,可传 activate=true 激活。 */
74
+ add: (item: TabItem, id?: string, activate?: boolean) => string;
75
+ /** 按 key 移除(key 即 add 返回的字符串,或静态项 'seed-<i>')。 */
76
+ remove: (key: string) => void;
77
+ /** 按「合并后列表」的 index 移除(与 onClose 回调的 index 一致)。 */
78
+ removeAt: (index: number) => void;
79
+ /** 按 key 激活某个 tab。 */
80
+ activate: (key: string) => void;
81
+ /** 取当前存活(未移除)的 tab 列表(含动态项)。 */
82
+ getItems: () => TabItem[];
83
+ }
84
+
85
+ const buildTabCls = (item: TabItem): string => {
86
+ let cls = item.closable ? 'x-tab-strip-closable' : '';
87
+ if (item.disabled) cls += ' x-item-disabled';
88
+ if (item.iconCls) cls += ' x-tab-with-icon';
89
+ if (item.tabCls) cls += ' ' + item.tabCls;
90
+ return cls.trim();
91
+ };
92
+
93
+ interface ManagedTab {
94
+ key: string;
95
+ item: TabItem;
96
+ }
97
+
98
+ export const TabPanel = forwardRef<TabPanelHandle, TabPanelProps>(function TabPanel(
99
+ {
100
+ items,
101
+ activeTab = 0,
102
+ tabPosition = 'top',
103
+ plain = false,
104
+ border = true,
105
+ enableTabScroll = false,
106
+ closeMenu = false,
107
+ scrollerMenu = false,
108
+ width,
109
+ height,
110
+ onTabChange,
111
+ onBeforeTabChange,
112
+ onClose,
113
+ },
114
+ ref,
115
+ ) {
116
+ const [activeIndex, setActiveIndex] = useState(activeTab);
117
+ const [hovered, setHovered] = useState<number | null>(null);
118
+ // removed 基于 managed key(双轨:items 为静态源,dynamic 为运行时追加)
119
+ const [removed, setRemoved] = useState<Set<string>>(new Set());
120
+ const [dynamic, setDynamic] = useState<ManagedTab[]>([]);
121
+ const seqRef = useRef(0);
122
+
123
+ // —— 插件:TabCloseMenu —— 右键 tab 弹出关闭菜单
124
+ const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; index: number } | null>(null);
125
+ const ctxMenuRef = useRef<HTMLDivElement>(null);
126
+
127
+ // —— 插件:TabScrollerMenu —— 滚动条旁下拉菜单按钮,列出全部 tab
128
+ const [scrollerMenuOpen, setScrollerMenuOpen] = useState<{ x: number; y: number } | null>(null);
129
+ const scrollerMenuRef = useRef<HTMLDivElement>(null);
130
+
131
+ // 统一关闭浮层菜单(外部点击 / Esc)
132
+ React.useEffect(() => {
133
+ if (!ctxMenu && !scrollerMenuOpen) return;
134
+ const onDown = (e: MouseEvent) => {
135
+ const t = e.target as HTMLElement;
136
+ if (ctxMenuRef.current && ctxMenuRef.current.contains(t)) return;
137
+ if (scrollerMenuRef.current && scrollerMenuRef.current.contains(t)) return;
138
+ setCtxMenu(null);
139
+ setScrollerMenuOpen(null);
140
+ };
141
+ const onKey = (e: KeyboardEvent) => {
142
+ if (e.key === 'Escape') {
143
+ setCtxMenu(null);
144
+ setScrollerMenuOpen(null);
145
+ }
146
+ };
147
+ document.addEventListener('mousedown', onDown);
148
+ document.addEventListener('keydown', onKey);
149
+ return () => {
150
+ document.removeEventListener('mousedown', onDown);
151
+ document.removeEventListener('keydown', onKey);
152
+ };
153
+ }, [ctxMenu, scrollerMenuOpen]);
154
+
155
+ const staticManaged = useMemo(
156
+ () => items.map((it, i) => ({ key: `seed-${i}`, item: it })),
157
+ [items],
158
+ );
159
+ const managed = useMemo<ManagedTab[]>(
160
+ () => [...staticManaged, ...dynamic],
161
+ [staticManaged, dynamic],
162
+ );
163
+
164
+ // 合并列表索引(含已移除占位)即「managed index」,非动态场景恒等于原始 items index。
165
+ const visibleItems = useMemo(
166
+ () =>
167
+ managed
168
+ .map((m, i) => ({ ...m, i }))
169
+ .filter(({ key }) => !removed.has(key)),
170
+ [managed, removed],
171
+ );
172
+
173
+ const activate = useCallback(
174
+ (index: number) => {
175
+ const m = managed[index];
176
+ if (!m || index === activeIndex) return;
177
+ if (m.item.disabled) return;
178
+ if (onBeforeTabChange && onBeforeTabChange(index) === false) return;
179
+ setActiveIndex(index);
180
+ onTabChange?.(index);
181
+ },
182
+ [managed, activeIndex, onBeforeTabChange, onTabChange],
183
+ );
184
+
185
+ const close = useCallback(
186
+ (index: number) => {
187
+ const m = managed[index];
188
+ if (!m?.item.closable) return;
189
+ onClose?.(index);
190
+ setRemoved((prev) => {
191
+ const next = new Set(prev);
192
+ next.add(m.key);
193
+ // 激活相邻 tab(优先右侧,否则左侧),与 Ext onRemove 624 行为一致
194
+ if (index === activeIndex) {
195
+ const remaining = visibleItems
196
+ .map(({ i }) => i)
197
+ .filter((i) => i !== index);
198
+ const neighbor = remaining[0] ?? 0;
199
+ setActiveIndex(neighbor);
200
+ }
201
+ return next;
202
+ });
203
+ },
204
+ [managed, activeIndex, visibleItems, onClose],
205
+ );
206
+
207
+ // —— 插件:TabCloseMenu 的「关闭其他 / 关闭全部」辅助 ——
208
+ const closeOthers = useCallback(
209
+ (index: number) => {
210
+ managed.forEach((_, i) => {
211
+ if (i !== index) close(i);
212
+ });
213
+ setActiveIndex(index);
214
+ setCtxMenu(null);
215
+ },
216
+ [managed, close],
217
+ );
218
+ const closeAll = useCallback(() => {
219
+ managed.forEach((_, i) => close(i));
220
+ setCtxMenu(null);
221
+ }, [managed, close]);
222
+
223
+ // —— 插件:TabCloseMenu 右键菜单项 ——
224
+ const ctxMenuItems: MenuItem[] = ctxMenu
225
+ ? [
226
+ { text: '关闭', handler: () => { close(ctxMenu.index); setCtxMenu(null); } },
227
+ { text: '关闭其他', handler: () => closeOthers(ctxMenu.index) },
228
+ { text: '关闭全部', handler: () => closeAll() },
229
+ ]
230
+ : [];
231
+
232
+ // —— 插件:TabScrollerMenu 下拉项(列出全部 tab)——
233
+ const scrollMenuItems: MenuItem[] = scrollerMenuOpen
234
+ ? visibleItems.map(({ item, i }) => ({
235
+ text: item.title,
236
+ disabled: i === activeIndex,
237
+ handler: () => {
238
+ activate(i);
239
+ setScrollerMenuOpen(null);
240
+ },
241
+ }))
242
+ : [];
243
+
244
+ useImperativeHandle(
245
+ ref,
246
+ () => ({
247
+ add: (item, id, activateTab) => {
248
+ const key = id ?? `tab-${++seqRef.current}`;
249
+ // 新 tab 追加在末尾,其 managed index = 静态项数 + 当前动态项数(追加前)
250
+ const newIndex = staticManaged.length + dynamic.length;
251
+ setDynamic((prev) => [...prev, { key, item }]);
252
+ if (activateTab) setActiveIndex(newIndex);
253
+ return key;
254
+ },
255
+ remove: (key) => {
256
+ const idx = managed.findIndex((m) => m.key === key);
257
+ if (idx >= 0) close(idx);
258
+ },
259
+ removeAt: (index) => close(index),
260
+ activate: (key) => {
261
+ const idx = managed.findIndex((m) => m.key === key);
262
+ if (idx >= 0) activate(idx);
263
+ },
264
+ getItems: () =>
265
+ managed.filter((m) => !removed.has(m.key)).map((m) => m.item),
266
+ }),
267
+ [managed, dynamic, staticManaged, close, activate, removed],
268
+ );
269
+
270
+ // Tab 溢出滚动(对齐 Ext.TabPanel enableTabScroll + TabStrip 的 scroller):
271
+ // 当 ul.x-tab-strip 内容总宽超过 scroll-wrap 可见宽时,渲染左右滚动箭头。
272
+ const stripWrapRef = React.useRef<HTMLDivElement>(null);
273
+ const stripRef = React.useRef<HTMLUListElement>(null);
274
+ const [scroller, setScroller] = React.useState(false);
275
+ const [scrollPos, setScrollPos] = React.useState(0);
276
+ const [scrollMeta, setScrollMeta] = React.useState<{ max: number; leftDisabled: boolean; rightDisabled: boolean }>({
277
+ max: 0,
278
+ leftDisabled: true,
279
+ rightDisabled: false,
280
+ });
281
+
282
+ // 箭头高度与 strip-wrap(tab item 区)等高,对齐 Ext.TabPanel 源码
283
+ // scroller.setSize(undefined, stripWrap.getHeight()) 行为
284
+ const [arrowHeight, setArrowHeight] = React.useState<number | undefined>(undefined);
285
+
286
+ const measureScroll = React.useCallback(() => {
287
+ const wrap = stripWrapRef.current;
288
+ const strip = stripRef.current;
289
+ if (!wrap || !strip) return;
290
+ const max = Math.max(0, strip.scrollWidth - wrap.clientWidth);
291
+ setScroller(strip.scrollWidth - wrap.clientWidth > 1);
292
+ setScrollMeta({ max, leftDisabled: scrollPos <= 0, rightDisabled: scrollPos >= max });
293
+ }, [scrollPos]);
294
+
295
+ // 初次 / tabs 变化时判定是否需滚动;scroller 出现后 strip-wrap 自动 margin 18(见 CSS),
296
+ // 因此 clientWidth 变小,需在 scroller 切换后重新测量一次。
297
+ React.useLayoutEffect(() => {
298
+ if (!enableTabScroll) return;
299
+ measureScroll();
300
+ }, [enableTabScroll, visibleItems.length, scroller, measureScroll]);
301
+
302
+ // 同步 scrollLeft 到 DOM(对齐 Ext.scrollToTab 的 scroll-wrap.scrollLeft 调整)
303
+ React.useLayoutEffect(() => {
304
+ if (stripWrapRef.current) stripWrapRef.current.scrollLeft = scrollPos;
305
+ }, [scrollPos, scroller]);
306
+
307
+ // 同步箭头高度到 strip-wrap(对齐源码 scroller.setSize(undefined, stripWrap.getHeight))
308
+ React.useLayoutEffect(() => {
309
+ if (!enableTabScroll || !stripWrapRef.current) return;
310
+ setArrowHeight(stripWrapRef.current.offsetHeight);
311
+ }, [enableTabScroll, visibleItems.length]);
312
+
313
+ const scrollBy = (delta: number) => {
314
+ const wrap = stripWrapRef.current;
315
+ const strip = stripRef.current;
316
+ if (!wrap || !strip) return;
317
+ const max = Math.max(0, strip.scrollWidth - wrap.clientWidth);
318
+ const next = Math.max(0, Math.min(max, scrollPos + delta));
319
+ setScrollPos(next);
320
+ };
321
+
322
+ const onKeyDown = useCallback(
323
+ (e: React.KeyboardEvent) => {
324
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
325
+ e.preventDefault();
326
+ const dir = e.key === 'ArrowRight' ? 1 : -1;
327
+ const list = visibleItems.map(({ i }) => i);
328
+ const pos = list.indexOf(activeIndex);
329
+ for (let step = 1; step <= list.length; step++) {
330
+ const cand = list[(pos + dir * step + list.length) % list.length];
331
+ if (!managed[cand].item.disabled) {
332
+ activate(cand);
333
+ break;
334
+ }
335
+ }
336
+ },
337
+ [visibleItems, activeIndex, managed, activate],
338
+ );
339
+
340
+ const renderStrip = (position: 'top' | 'bottom') => {
341
+ return (
342
+ <>
343
+ {scroller && (
344
+ <div
345
+ className={`x-tab-scroller-left${scrollMeta.leftDisabled ? ' x-tab-scroller-left-disabled' : ''}`}
346
+ style={{ height: arrowHeight }}
347
+ onClick={() => !scrollMeta.leftDisabled && scrollBy(-120)}
348
+ />
349
+ )}
350
+ {/* 对齐 Ext onRender 324-325:bottom 时 x-tab-strip-spacer 插在 strip-wrap 之前,top 时在其后 */}
351
+ {position === 'bottom' && <div className="x-tab-strip-spacer" />}
352
+ <div
353
+ className="x-tab-strip-wrap"
354
+ ref={stripWrapRef}
355
+ style={{ overflow: 'hidden', marginRight: scroller && scrollerMenu ? 36 : undefined }}
356
+ >
357
+ <ul
358
+ ref={stripRef}
359
+ className={`x-tab-strip x-tab-strip-${position}`}
360
+ role="tablist"
361
+ onKeyDown={onKeyDown}
362
+ >
363
+ {visibleItems.map(({ key, item, i }) => {
364
+ const cls = [
365
+ buildTabCls(item),
366
+ i === activeIndex ? 'x-tab-strip-active' : '',
367
+ hovered === i && !item.disabled ? 'x-tab-strip-over' : '',
368
+ ]
369
+ .filter(Boolean)
370
+ .join(' ');
371
+ return (
372
+ <li
373
+ key={key}
374
+ id={item.id}
375
+ className={cls}
376
+ role="tab"
377
+ aria-selected={i === activeIndex}
378
+ tabIndex={i === activeIndex ? 0 : -1}
379
+ onMouseEnter={() => setHovered(i)}
380
+ onMouseLeave={() => setHovered((h) => (h === i ? null : h))}
381
+ onClick={() => activate(i)}
382
+ onContextMenu={
383
+ closeMenu
384
+ ? (e) => {
385
+ e.preventDefault();
386
+ setCtxMenu({ x: e.clientX, y: e.clientY, index: i });
387
+ }
388
+ : undefined
389
+ }
390
+ >
391
+ <a
392
+ className="x-tab-strip-close"
393
+ onClick={(e) => {
394
+ e.stopPropagation();
395
+ if (item.closable) close(i);
396
+ }}
397
+ />
398
+ <a className="x-tab-right" href="#" onClick={(e) => e.preventDefault()}>
399
+ <em className="x-tab-left">
400
+ <span className="x-tab-strip-inner">
401
+ <span className={`x-tab-strip-text ${item.iconCls || ''}`}>
402
+ {item.title}
403
+ </span>
404
+ </span>
405
+ </em>
406
+ </a>
407
+ </li>
408
+ );
409
+ })}
410
+ <li className="x-tab-edge">
411
+ <span className="x-tab-strip-text">&#160;</span>
412
+ </li>
413
+ <div className="x-clear" style={{ width: 0 }} />
414
+ </ul>
415
+ </div>
416
+ {scroller && (
417
+ <div
418
+ className={`x-tab-scroller-right${scrollMeta.rightDisabled ? ' x-tab-scroller-right-disabled' : ''}`}
419
+ style={{
420
+ height: arrowHeight,
421
+ ...(scrollerMenu ? { right: 18 } : {}),
422
+ }}
423
+ onClick={() => !scrollMeta.rightDisabled && scrollBy(120)}
424
+ />
425
+ )}
426
+ {scroller && scrollerMenu && (
427
+ <div
428
+ className="x-tab-tabmenu-right"
429
+ style={{ height: arrowHeight, bottom: 'auto' }}
430
+ onClick={(e) => {
431
+ const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
432
+ setScrollerMenuOpen({ x: r.left, y: r.bottom });
433
+ }}
434
+ />
435
+ )}
436
+ {position === 'top' && <div className="x-tab-strip-spacer" />}
437
+ </>
438
+ );
439
+ };
440
+
441
+ const scrollerMod = tabPosition === 'bottom' ? 'x-tab-scrolling-bottom' : 'x-tab-scrolling-top';
442
+ // 对齐原始 Ext.TabPanel:tabPosition==='bottom' 时用 footer 元素
443
+ // (this.pos = footer,见 TabPanel.js),footer 含 padding-top:2px,
444
+ // 避免 strip 被顶到上边框。
445
+ const stripBaseCls = tabPosition === 'bottom' ? 'x-tab-panel-footer' : 'x-tab-panel-header';
446
+ const plainCls = plain
447
+ ? tabPosition === 'bottom'
448
+ ? 'x-tab-panel-footer-plain'
449
+ : 'x-tab-panel-header-plain'
450
+ : '';
451
+ // border:false 时 header/footer 也加 -noborder(对齐 Ext:x-tab-panel-header-noborder)。
452
+ const noborderCls = border ? '' : `${stripBaseCls}-noborder`;
453
+ const headerCls = [
454
+ stripBaseCls,
455
+ noborderCls,
456
+ 'x-unselectable',
457
+ plainCls,
458
+ scroller ? `x-tab-scrolling ${scrollerMod}` : '',
459
+ ]
460
+ .filter(Boolean)
461
+ .join(' ');
462
+
463
+ // 对齐 Ext Panel 结构(onRender):body 由 baseCls+'-body' 命名,底部无 header 时加
464
+ // x-tab-panel-body-noheader(Ext 源码 addClass('x-panel-body-noheader' 分支),与 body-{pos} 对应。
465
+ const bodyCls = [
466
+ 'x-panel-body',
467
+ 'x-tab-panel-body',
468
+ tabPosition === 'bottom' ? 'x-tab-panel-body-noheader' : '',
469
+ `x-tab-panel-body-${tabPosition}`,
470
+ ]
471
+ .filter(Boolean)
472
+ .join(' ');
473
+
474
+ const rootCls = ['x-tab-panel', border ? '' : 'x-tab-panel-noborder'].filter(Boolean).join(' ');
475
+
476
+ // 对齐 Ext 结构:面板 root 下是 x-tab-panel-bwrap 包裹体(Panel.js body wrap),
477
+ // top 时 header 是 root 直接子元素、body 在 bwrap 内;bottom 时 footer 也在 bwrap 内、
478
+ // 排在 body 之后(见真实 Ext 运行时 DOM)。bwrap/body 用内联 flex 撑满,
479
+ // 复现 Ext 由 layout 计算 body 高度(height - docked 高度)的效果,使 footer 贴底、不悬空。
480
+ const rootStyle: React.CSSProperties = { width, height, display: 'flex', flexDirection: 'column' };
481
+ const bwrapStyle: React.CSSProperties = {
482
+ display: 'flex',
483
+ flexDirection: 'column',
484
+ flex: '1 1 auto',
485
+ minHeight: 0,
486
+ };
487
+ const bodyStyle: React.CSSProperties = { flex: '1 1 auto', minHeight: 0 };
488
+
489
+ return (
490
+ <div className={rootCls} style={rootStyle}>
491
+ {tabPosition === 'top' && (
492
+ <div className={headerCls}>
493
+ {renderStrip('top')}
494
+ </div>
495
+ )}
496
+ <div className="x-tab-panel-bwrap" style={bwrapStyle}>
497
+ <div className={bodyCls} style={bodyStyle}>
498
+ {visibleItems.map(({ key, item, i }) => (
499
+ <div
500
+ key={key}
501
+ role="tabpanel"
502
+ className={i === activeIndex ? '' : 'x-hide-display'}
503
+ >
504
+ {item.content}
505
+ </div>
506
+ ))}
507
+ </div>
508
+ {tabPosition === 'bottom' && (
509
+ <div className={headerCls}>
510
+ {renderStrip('bottom')}
511
+ </div>
512
+ )}
513
+ </div>
514
+
515
+ {createPortal(
516
+ <>
517
+ {ctxMenu && (
518
+ <div
519
+ ref={ctxMenuRef}
520
+ style={{ position: 'absolute', left: ctxMenu.x, top: ctxMenu.y, zIndex: 15000 }}
521
+ >
522
+ <Menu items={ctxMenuItems} />
523
+ </div>
524
+ )}
525
+ {scrollerMenuOpen && (
526
+ <div
527
+ ref={scrollerMenuRef}
528
+ style={{ position: 'absolute', left: scrollerMenuOpen.x, top: scrollerMenuOpen.y, zIndex: 15000 }}
529
+ >
530
+ <Menu items={scrollMenuItems} />
531
+ </div>
532
+ )}
533
+ </>,
534
+ document.body,
535
+ )}
536
+ </div>
537
+ );
538
+ });
539
+
540
+ export default TabPanel;
@@ -0,0 +1,132 @@
1
+ import React from 'react';
2
+ import { Panel } from './Panel';
3
+
4
+ export interface TableItem {
5
+ title?: string;
6
+ /** 子 Panel 正文(对齐 Ext Panel.html,作为 body 直接子节点) */
7
+ html?: string;
8
+ width?: number;
9
+ height?: number;
10
+ frame?: boolean;
11
+ /** 跨列数(对齐 Ext item.colspan) */
12
+ colspan?: number;
13
+ /** 跨行数(对齐 Ext item.rowspan) */
14
+ rowspan?: number;
15
+ /** 附加在 td 上的 class(对齐 Ext item.cellCls) */
16
+ cellCls?: string;
17
+ /** body 内边距(透传给子 Panel 的 bodyPadding) */
18
+ bodyPadding?: number | string;
19
+ }
20
+
21
+ export interface TableLayoutProps {
22
+ title?: string;
23
+ width?: number;
24
+ /** 列数(对齐 Ext layoutConfig.columns) */
25
+ columns: number;
26
+ items: TableItem[];
27
+ frame?: boolean;
28
+ testId?: string;
29
+ style?: React.CSSProperties;
30
+ }
31
+
32
+ interface Placement {
33
+ item: TableItem;
34
+ row: number;
35
+ col: number;
36
+ colspan: number;
37
+ rowspan: number;
38
+ }
39
+
40
+ /**
41
+ * 保真映射(来源 Ext.layout.TableLayout + Ext.Panel):
42
+ * - 根 Panel 的 body 加 `x-table-layout-ct`(target.addClass,见 onLayout 113 行)。
43
+ * - body 直接子节点为 `<table class="x-table-layout" cellspacing="0">` > `<tbody>` > `<tr>` > `<td class="x-table-layout-cell">`。
44
+ * - 放置算法逐行移植 getNextNonSpan(163)/getNextCell(132):
45
+ * cells 占用图 + 从当前 (col,row) 推进到下一个空闲格(col>=columns 则换行 col=0);
46
+ * 标记矩形占用后创建 td(带 colspan/rowspan/cellCls),append 到对应 row 的 tr。
47
+ * **关键**:仅对“起始行”创建 <tr>(rowspan 跨越的行不产生 tr,由浏览器隐式延续),
48
+ * 每行内 td 按“列递增”顺序(即 item 处理顺序)append——与 Ext 完全一致;空缺格不生成占位 td。
49
+ * - 无任何专属 CSS 规则(ext-all.css 无 .x-table-layout*),视觉纯来自浏览器默认表格渲染 +
50
+ * 单元格内 Panel 自身样式;td padding 0、content-box,故只要结构复制一致即像素一致。
51
+ */
52
+ export function TableLayout({ title, width, columns, items, frame = false, testId, style }: TableLayoutProps) {
53
+ const placements: Placement[] = React.useMemo(() => {
54
+ const cells: boolean[][] = [];
55
+ let curCol = 0;
56
+ let curRow = 0;
57
+ const out: Placement[] = [];
58
+
59
+ const getNextNonSpan = (colIndex: number, rowIndex: number): [number, number] => {
60
+ while ((columns && colIndex >= columns) || (cells[rowIndex] && cells[rowIndex][colIndex])) {
61
+ if (columns && colIndex >= columns) {
62
+ rowIndex += 1;
63
+ colIndex = 0;
64
+ } else {
65
+ colIndex += 1;
66
+ }
67
+ }
68
+ return [colIndex, rowIndex];
69
+ };
70
+
71
+ for (const item of items) {
72
+ const colspan = item.colspan || 1;
73
+ const rowspan = item.rowspan || 1;
74
+ const [c, r] = getNextNonSpan(curCol, curRow);
75
+ curCol = c;
76
+ curRow = r;
77
+ for (let ri = r; ri < r + rowspan; ri += 1) {
78
+ if (!cells[ri]) cells[ri] = [];
79
+ for (let ci = c; ci < c + colspan; ci += 1) {
80
+ cells[ri][ci] = true;
81
+ }
82
+ }
83
+ out.push({ item, row: r, col: c, colspan, rowspan });
84
+ }
85
+ return out;
86
+ }, [items, columns]);
87
+
88
+ // 仅“起始行”需要 <tr>,按升序;行内 td 保持处理顺序(列递增)即与 Ext 一致
89
+ const startRows = Array.from(new Set(placements.map((p) => p.row))).sort((a, b) => a - b);
90
+ const byRow = (r: number) => placements.filter((p) => p.row === r);
91
+
92
+ const renderItem = (item: TableItem) => (
93
+ <Panel
94
+ title={item.title}
95
+ width={item.width}
96
+ height={item.height}
97
+ frame={item.frame ?? true}
98
+ bodyPadding={item.bodyPadding ?? 0}
99
+ >
100
+ {item.html !== undefined ? (
101
+ <span dangerouslySetInnerHTML={{ __html: item.html }} />
102
+ ) : null}
103
+ </Panel>
104
+ );
105
+
106
+ return (
107
+ <div data-testid={testId} style={style}>
108
+ <Panel title={title} width={width} frame={frame} bodyClassName="x-table-layout-ct">
109
+ <table className="x-table-layout" cellSpacing={0}>
110
+ <tbody>
111
+ {startRows.map((r) => (
112
+ <tr key={r}>
113
+ {byRow(r).map((p, i) => (
114
+ <td
115
+ key={i}
116
+ className={['x-table-layout-cell', p.item.cellCls].filter(Boolean).join(' ')}
117
+ colSpan={p.colspan > 1 ? p.colspan : undefined}
118
+ rowSpan={p.rowspan > 1 ? p.rowspan : undefined}
119
+ >
120
+ {renderItem(p.item)}
121
+ </td>
122
+ ))}
123
+ </tr>
124
+ ))}
125
+ </tbody>
126
+ </table>
127
+ </Panel>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ export default TableLayout;