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,289 @@
1
+ import { useMemo, type CSSProperties, type ReactNode } from 'react';
2
+
3
+ export interface BoxItem {
4
+ /** 子项显式宽(px)。hbox 中固定宽;缺省且给 flex 则按比例分配。 */
5
+ width?: number;
6
+ /** 子项显式高(px)。vbox 中固定高;缺省且给 flex 则按比例分配。 */
7
+ height?: number;
8
+ /** 弹性系数(hbox 横向 / vbox 纵向)。仅当对应方向缺省宽/高时参与分配。 */
9
+ flex?: number;
10
+ /** 子项外边距 {top,right,bottom,left},缺省用 Box 的 defaultMargins。 */
11
+ margins?: { top?: number; right?: number; bottom?: number; left?: number };
12
+ /** 子项最小/最大尺寸(stretch 时钳制)。 */
13
+ minWidth?: number;
14
+ maxWidth?: number;
15
+ minHeight?: number;
16
+ maxHeight?: number;
17
+ style?: CSSProperties;
18
+ className?: string;
19
+ children?: ReactNode;
20
+ }
21
+
22
+ export interface BoxProps {
23
+ /** 'hbox' 横向 / 'vbox' 纵向。 */
24
+ layout: 'hbox' | 'vbox';
25
+ width?: number;
26
+ height?: number;
27
+ /** start(默认) / center / end。 */
28
+ pack?: 'start' | 'center' | 'end';
29
+ /** hbox: top(默认)/middle/stretch;vbox: left(默认)/center/stretch/stretchmax。 */
30
+ align?: string;
31
+ /** 容器内边距,字符串 "t r b l"(同 CSS 简写)。默认 '0'。 */
32
+ padding?: string;
33
+ /** 子项默认外边距,字符串简写。默认 '0'。 */
34
+ defaultMargins?: string;
35
+ cls?: string;
36
+ style?: CSSProperties;
37
+ testId?: string;
38
+ id?: string;
39
+ items?: BoxItem[];
40
+ }
41
+
42
+ type Margins = { top: number; right: number; bottom: number; left: number };
43
+
44
+ const parseMargins = (v: string | undefined): Margins => {
45
+ const p = (v || '0').trim().split(/\s+/).map(Number);
46
+ // CSS 简写:1→四边, 2→(上下)(左右), 3→(上)(左右)(下), 4→(上)(右)(下)(左)
47
+ const top = p[0] ?? 0;
48
+ const right = p[1] != null ? p[1] : p[0] ?? 0;
49
+ const bottom = p[2] != null ? p[2] : p[0] ?? 0;
50
+ const left = p[3] != null ? p[3] : p[1] != null ? p[1] : p[0] ?? 0;
51
+ return { top, right, bottom, left };
52
+ };
53
+
54
+ const constrain = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
55
+
56
+ /**
57
+ * 复刻 Ext.layout.BoxLayout(HBox/VBox,Ext 3.2.1)。
58
+ *
59
+ * DOM 对齐 BoxLayout.js 真值:
60
+ * - 容器布局目标加 targetCls='x-box-layout-ct'(此处挂于根 .x-container 上);
61
+ * - renderAll 创建 innerCt = <div class="x-box-inner">(position:relative);
62
+ * - 每个子组件元素追加 extraCls='x-box-item' 并绝对定位(left/top/width/height)。
63
+ * React 侧子项即 <div class="x-component x-box-item" style="position:absolute;...">。
64
+ *
65
+ * 布局算法逐行移植 calculateChildBoxes(HBox/VBox 各分支)+ updateInnerCtSize:
66
+ * - 仅支持数值宽高/flex(避免依赖真实 DOM 测量,保证 golden 与 react 几何一致);
67
+ * - pack(start/center/end) 控制主轴的起始偏移;align 控制交叉轴对齐/拉伸;
68
+ * - flex 项按 ceil((flex/remainingFlex)*remaining) 分配剩余空间(对齐 Ext.ceil 取整)。
69
+ *
70
+ * 全部视觉来自 ext-all.css 的 .x-box-layout-ct/.x-box-inner/.x-box-item(共 3 条规则),
71
+ * 外加 .x-container/.x-component 无样式基础类,零新增布局 CSS、零硬编码主题色。
72
+ * 原生 BoxLayout 的 box-item 本身不绘制边框(仅 position:absolute 定位),边框由子项自身提供
73
+ * (BoxComponent 自带背景、Panel 经 ext-all/xtheme 渲染原生边框),相邻单元格不会出现 2px 叠加边框;
74
+ * 容器 .x-box-layout-ct/.x-box-inner 按原生设 overflow:hidden 裁剪溢出。子面板(Panel)保留原生
75
+ * border(颜色由 xtheme 提供、主题切换自动跟随),不强制 border=false、不写死颜色。
76
+ */
77
+ export function Box(props: BoxProps) {
78
+ const {
79
+ layout,
80
+ width,
81
+ height,
82
+ pack = 'start',
83
+ align,
84
+ padding = '0',
85
+ defaultMargins = '0',
86
+ cls = '',
87
+ style,
88
+ testId,
89
+ id,
90
+ items = [],
91
+ } = props;
92
+
93
+ const pad = parseMargins(padding);
94
+ const defMar = parseMargins(defaultMargins);
95
+ const isHBox = layout === 'hbox';
96
+ const alignResolved = align ?? (isHBox ? 'top' : 'left');
97
+ const isStart = pack === 'start';
98
+ const isCenter = pack === 'center';
99
+ const isEnd = pack === 'end';
100
+
101
+ const result = useMemo(() => {
102
+ const tSize = { width: width ?? 0, height: height ?? 0 };
103
+ const padVert = pad.top + pad.bottom;
104
+ const padHoriz = pad.left + pad.right;
105
+
106
+ const prepared = items.map((it) => {
107
+ const m = it.margins ? parseMarginsObj(it.margins) : defMar;
108
+ return {
109
+ width: it.width,
110
+ height: it.height,
111
+ flex: it.flex,
112
+ minWidth: it.minWidth ?? 0,
113
+ maxWidth: it.maxWidth ?? 1000000,
114
+ minHeight: it.minHeight ?? 0,
115
+ maxHeight: it.maxHeight ?? 1000000,
116
+ margins: m,
117
+ };
118
+ });
119
+
120
+ if (isHBox) {
121
+ const availHeight = Math.max(0, tSize.height - padVert);
122
+ let nonFlexWidth = 0;
123
+ let maxHeight = 0;
124
+ let totalFlex = 0;
125
+ prepared.forEach((b) => {
126
+ nonFlexWidth += (b.width ?? 0) + b.margins.left + b.margins.right;
127
+ maxHeight = Math.max(maxHeight, (b.height ?? 0) + b.margins.top + b.margins.bottom);
128
+ if (b.flex && !b.width) totalFlex += b.flex;
129
+ });
130
+ const availableWidth = Math.max(0, tSize.width - nonFlexWidth - padHoriz);
131
+ let leftOffset = pad.left;
132
+ if (isCenter) leftOffset += availableWidth / 2;
133
+ else if (isEnd) leftOffset += availableWidth;
134
+ let remainingWidth = availableWidth;
135
+ let remainingFlex = totalFlex;
136
+
137
+ const boxes = prepared.map((b) => {
138
+ const m = b.margins;
139
+ leftOffset += m.left;
140
+ const calcs: { left: number; top: number; width: number; height: number } = {
141
+ left: leftOffset,
142
+ top: pad.top + m.top,
143
+ width: b.width ?? 0,
144
+ height: b.height ?? 0,
145
+ };
146
+ if (isStart && b.flex && !b.width) {
147
+ const fw = Math.ceil((b.flex / remainingFlex) * remainingWidth);
148
+ remainingWidth -= fw;
149
+ remainingFlex -= b.flex;
150
+ calcs.width = fw;
151
+ }
152
+ switch (alignResolved) {
153
+ case 'stretch':
154
+ calcs.height = constrain(availHeight - m.top - m.bottom, b.minHeight, b.maxHeight);
155
+ break;
156
+ case 'stretchmax':
157
+ calcs.height = constrain(maxHeight - m.top - m.bottom, b.minHeight, b.maxHeight);
158
+ break;
159
+ case 'middle': {
160
+ const diff = availHeight - calcs.height - m.top - m.bottom;
161
+ if (diff > 0) calcs.top = pad.top + m.top + m.bottom + diff / 2;
162
+ break;
163
+ }
164
+ }
165
+ leftOffset += calcs.width + m.right;
166
+ return calcs;
167
+ });
168
+
169
+ const innerH =
170
+ alignResolved === 'stretch'
171
+ ? tSize.height
172
+ : alignResolved === 'middle'
173
+ ? Math.max(tSize.height, maxHeight + padVert)
174
+ : maxHeight + padVert;
175
+ return { boxes, innerW: tSize.width, innerH };
176
+ } else {
177
+ const availWidth = Math.max(0, tSize.width - padHoriz);
178
+ let nonFlexHeight = 0;
179
+ let maxWidth = 0;
180
+ let totalFlex = 0;
181
+ prepared.forEach((b) => {
182
+ nonFlexHeight += (b.height ?? 0) + b.margins.top + b.margins.bottom;
183
+ maxWidth = Math.max(maxWidth, (b.width ?? 0) + b.margins.left + b.margins.right);
184
+ if (b.flex && !b.height) totalFlex += b.flex;
185
+ });
186
+ const availableHeight = Math.max(0, tSize.height - nonFlexHeight - padVert);
187
+ let topOffset = pad.top;
188
+ if (isCenter) topOffset += availableHeight / 2;
189
+ else if (isEnd) topOffset += availableHeight;
190
+ let remainingHeight = availableHeight;
191
+ let remainingFlex = totalFlex;
192
+
193
+ const boxes = prepared.map((b) => {
194
+ const m = b.margins;
195
+ topOffset += m.top;
196
+ const calcs: { left: number; top: number; width: number; height: number } = {
197
+ left: pad.left + m.left,
198
+ top: topOffset,
199
+ width: b.width ?? 0,
200
+ height: b.height ?? 0,
201
+ };
202
+ if (isStart && b.flex && !b.height) {
203
+ const fh = Math.ceil((b.flex / remainingFlex) * remainingHeight);
204
+ remainingHeight -= fh;
205
+ remainingFlex -= b.flex;
206
+ calcs.height = fh;
207
+ }
208
+ switch (alignResolved) {
209
+ case 'stretch':
210
+ calcs.width = constrain(availWidth - m.left - m.right, b.minWidth, b.maxWidth);
211
+ break;
212
+ case 'stretchmax':
213
+ calcs.width = constrain(maxWidth - m.left - m.right, b.minWidth, b.maxWidth);
214
+ break;
215
+ case 'center': {
216
+ const diff = availWidth - calcs.width - m.left - m.right;
217
+ if (diff > 0) calcs.left = pad.left + m.left + m.right + diff / 2;
218
+ break;
219
+ }
220
+ }
221
+ topOffset += calcs.height + m.bottom;
222
+ return calcs;
223
+ });
224
+
225
+ const innerW =
226
+ alignResolved === 'stretch'
227
+ ? tSize.width
228
+ : alignResolved === 'center'
229
+ ? Math.max(tSize.width, maxWidth + padHoriz)
230
+ : maxWidth + padHoriz;
231
+ return { boxes, innerW, innerH: tSize.height };
232
+ }
233
+ }, [items, width, height, pack, alignResolved, pad, defMar, isHBox]);
234
+
235
+ const rootStyle: CSSProperties = {
236
+ width,
237
+ height,
238
+ // 对齐原生 .x-box-layout-ct{overflow:hidden}:裁剪横向/纵向溢出内容(原生 box 布局容器行为)
239
+ overflow: 'hidden',
240
+ ...style,
241
+ };
242
+
243
+ return (
244
+ <div
245
+ id={id}
246
+ data-testid={testId}
247
+ className={['x-container', 'x-box-layout-ct', cls].filter(Boolean).join(' ')}
248
+ style={rootStyle}
249
+ >
250
+ <div
251
+ className="x-box-inner"
252
+ style={{ position: 'relative', width: result.innerW, height: result.innerH, overflow: 'hidden' }}
253
+ >
254
+ {items.map((it, i) => {
255
+ const b = result.boxes[i];
256
+ // 原生 BoxLayout:box-item 本身不绘制边框(仅 position:absolute 定位),边框由子项自身提供
257
+ // (Panel 经 ext-all/xtheme 渲染原生边框、颜色随主题切换自动跟随,此处不写死颜色)。
258
+ // 溢出裁剪交给外层 .x-box-inner(原生 .x-box-inner{overflow:hidden}),此处不重复。
259
+ const child = it.children;
260
+ return (
261
+ <div
262
+ key={i}
263
+ className={['x-component', 'x-box-item', it.className].filter(Boolean).join(' ')}
264
+ style={{
265
+ position: 'absolute',
266
+ left: b.left,
267
+ top: b.top,
268
+ width: b.width,
269
+ height: b.height,
270
+ ...it.style,
271
+ }}
272
+ >
273
+ {child}
274
+ </div>
275
+ );
276
+ })}
277
+ </div>
278
+ </div>
279
+ );
280
+ }
281
+
282
+ function parseMarginsObj(m: { top?: number; right?: number; bottom?: number; left?: number }): Margins {
283
+ return {
284
+ top: m.top ?? 0,
285
+ right: m.right ?? 0,
286
+ bottom: m.bottom ?? 0,
287
+ left: m.left ?? 0,
288
+ };
289
+ }
@@ -0,0 +1,300 @@
1
+ import React, { useState, useCallback, useEffect, useRef, useLayoutEffect } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { Menu, type MenuItem } from './Menu';
4
+
5
+ export type ButtonScale = 'small' | 'medium' | 'large';
6
+ export type ButtonIconAlign = 'top' | 'right' | 'bottom' | 'left';
7
+ export type ButtonArrowAlign = 'right' | 'bottom';
8
+ export type ButtonType = 'submit' | 'reset' | 'button';
9
+
10
+ export interface ButtonProps {
11
+ /** 按钮文字(允许 HTML,原版用 innerHTML) */
12
+ text?: string;
13
+ /** 图标图片路径(inline background-image) */
14
+ icon?: string;
15
+ /** 提供背景图的 CSS class(与 icon 二选一) */
16
+ iconCls?: string;
17
+ /** 尺寸:small=16px / medium=24px / large=32px */
18
+ scale?: ButtonScale;
19
+ /** 图标相对文字的位置 */
20
+ iconAlign?: ButtonIconAlign;
21
+ /** DOM button 类型 */
22
+ type?: ButtonType;
23
+ /** 附加在 <table> 上的自定义 class */
24
+ cls?: string;
25
+ disabled?: boolean;
26
+ /** 初始按下态(仅 enableToggle 时有效)。受控模式下表示当前是否按下。 */
27
+ pressed?: boolean;
28
+ enableToggle?: boolean;
29
+ /** 切换回调(enableToggle 时):每次切换后回调「新按下态」。
30
+ * 提供此回调即进入受控模式——pressed 完全由调用方 prop 驱动(单一数据源),
31
+ * 按钮自身不再自管 isPressed,避免「内部翻转 + 父级 handler 翻转」双重翻转导致的错位。 */
32
+ onToggle?: (pressed: boolean) => void;
33
+ /** 下拉菜单:传 MenuItem[] 渲染菜单并启用开关;传 true 仅显示箭头 */
34
+ menu?: MenuItem[] | boolean;
35
+ arrowAlign?: ButtonArrowAlign;
36
+ minWidth?: number;
37
+ /** 显式宽度(如 MessageBox 按钮固定 75px) */
38
+ width?: number;
39
+ /** 点击回调(替代原版 handler),仅当无 menu 时触发 */
40
+ handler?: () => void;
41
+ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
42
+ /** 菜单项点击回调 */
43
+ onMenuItemClick?: (item: MenuItem, index: number) => void;
44
+ /** 拆分按钮模式(Ext.SplitButton):em 加 x-btn-split,箭头区开菜单、主体走 handler */
45
+ split?: boolean;
46
+ /** 拆分按钮箭头点击回调(替代菜单展开) */
47
+ arrowHandler?: () => void;
48
+ /** 拆分按钮箭头提示文本(等价于 Ext.SplitButton.arrowTooltip,落到 em 的 title 属性) */
49
+ arrowTooltip?: string;
50
+ /** 透传到最外层 span 的内联样式(原生 DOM 属性,不进组件 CSS) */
51
+ style?: React.CSSProperties;
52
+ }
53
+
54
+ /**
55
+ * 保真映射(来源 src/widgets/Button.js):
56
+ * - DOM 模板:Ext.Button.buttonTemplate(第 307-312 行)
57
+ * - 模板参数:getTemplateArgs(第 282-284 行)
58
+ * - 状态类:setButtonClass / onMouseOver / onMouseDown / toggle(x-btn-over/focus/click/pressed/disabled/menu-active)
59
+ * React 仅重写行为与状态管理,DOM 结构与 class 名与 Ext JS 3.2.1 完全一致。
60
+ *
61
+ * 交互(行为契约,对应 Ext.Button + Ext.menu.Menu):
62
+ * - 有 menu 时点击按钮 -> 切换菜单开合(不触发 handler,等同 Ext 把 click 路由到菜单)
63
+ * - 菜单项点击 -> 执行 item.handler + onMenuItemClick,并关闭菜单
64
+ * - 外部点击 / Esc -> 关闭菜单(等同 Ext 的 document 级监听)
65
+ */
66
+ export function Button({
67
+ text = '',
68
+ icon,
69
+ iconCls,
70
+ scale = 'small',
71
+ iconAlign = 'left',
72
+ type = 'button',
73
+ cls = '',
74
+ disabled = false,
75
+ pressed = false,
76
+ enableToggle = false,
77
+ menu = false,
78
+ arrowAlign = 'right',
79
+ minWidth,
80
+ width,
81
+ handler,
82
+ onClick,
83
+ onToggle,
84
+ onMenuItemClick,
85
+ split = false,
86
+ arrowHandler,
87
+ arrowTooltip,
88
+ style,
89
+ }: ButtonProps) {
90
+ const [hovered, setHovered] = useState(false);
91
+ const [focused, setFocused] = useState(false);
92
+ const [clicked, setClicked] = useState(false);
93
+ // enableToggle 按钮自管 pressed 态(对齐 Ext:按钮内部维护 pressed,父级通过 onToggle 同步读取,
94
+ // 显示态只取自管 internalPressed,不回读 pressed prop,避免「内部翻转 + 父级 handler 翻转 +
95
+ // prop 回传」形成的反馈回环与错位)。
96
+ const [internalPressed, setInternalPressed] = useState(pressed);
97
+ const isPressed = enableToggle ? internalPressed : Boolean(pressed);
98
+ const [menuOpen, setMenuOpen] = useState(false);
99
+ const [arrowHovered, setArrowHovered] = useState(false);
100
+ const wrapRef = useRef<HTMLSpanElement>(null);
101
+ const buttonRef = useRef<HTMLButtonElement>(null);
102
+ const menuElRef = useRef<HTMLDivElement>(null);
103
+ // 菜单浮层定位(对齐 Ext:菜单渲染到 document 级浮层,避免被 overflow:hidden 容器裁切)
104
+ const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
105
+
106
+ const menuItems = Array.isArray(menu) ? menu : null;
107
+
108
+ // 外部点击 / Esc 关闭菜单(等同 Ext 的 document 级监听)。
109
+ // 菜单浮层已 portal 到 body,需同时排除浮层自身,否则点菜单会被误判为外部点击而先关闭。
110
+ useEffect(() => {
111
+ if (!menuOpen) return;
112
+ const onDocDown = (e: MouseEvent) => {
113
+ const t = e.target as Node;
114
+ if (wrapRef.current && !wrapRef.current.contains(t)) {
115
+ if (menuElRef.current && menuElRef.current.contains(t)) return;
116
+ setMenuOpen(false);
117
+ }
118
+ };
119
+ const onKey = (e: KeyboardEvent) => {
120
+ if (e.key === 'Escape') setMenuOpen(false);
121
+ };
122
+ document.addEventListener('mousedown', onDocDown);
123
+ document.addEventListener('keydown', onKey);
124
+ return () => {
125
+ document.removeEventListener('mousedown', onDocDown);
126
+ document.removeEventListener('keydown', onKey);
127
+ };
128
+ }, [menuOpen]);
129
+
130
+ // 按下态(x-btn-click)清除:mouseup 可能落在按钮外(按下后移到按钮外再松开),
131
+ // 绑在 button 自身的 onMouseUp 不会触发,导致按钮卡在按下态。改为在按下期间挂
132
+ // window 级 mouseup/pointerup,任何位置松开都清除,等价原生 button 的 :active 行为。
133
+ useEffect(() => {
134
+ if (!clicked) return;
135
+ const clear = () => setClicked(false);
136
+ window.addEventListener('mouseup', clear);
137
+ window.addEventListener('pointerup', clear);
138
+ return () => {
139
+ window.removeEventListener('mouseup', clear);
140
+ window.removeEventListener('pointerup', clear);
141
+ };
142
+ }, [clicked]);
143
+
144
+ // 打开时按按钮位置计算浮层坐标(视口定位,fixed),避开 overflow 容器裁切
145
+ useLayoutEffect(() => {
146
+ if (menuOpen && menuItems && buttonRef.current) {
147
+ const r = buttonRef.current.getBoundingClientRect();
148
+ setMenuPos({ top: r.bottom, left: r.left });
149
+ } else {
150
+ setMenuPos(null);
151
+ }
152
+ }, [menuOpen, menuItems]);
153
+
154
+ // 与 setButtonClass 完全一致:有图标且有文字 -> x-btn-text-icon;仅图标 -> x-btn-icon;无 -> x-btn-noicon
155
+ const iconStateClass = iconCls || icon
156
+ ? (text ? 'x-btn-text-icon' : 'x-btn-icon')
157
+ : 'x-btn-noicon';
158
+
159
+ // 与 Ext.Button 一致:em 只承载箭头类(getMenuClass,307-312 行模板参数 {2});
160
+ // iconCls 经 setIconClass 落在 <button>(带 x-btn-text),见 Button.js 567-574 行。
161
+ const emClass = split
162
+ ? arrowAlign === 'bottom'
163
+ ? 'x-btn-split-bottom'
164
+ : 'x-btn-split'
165
+ : menu
166
+ ? arrowAlign === 'bottom'
167
+ ? 'x-btn-arrow-bottom'
168
+ : 'x-btn-arrow'
169
+ : '';
170
+
171
+ const tableClass = [
172
+ 'x-btn',
173
+ cls,
174
+ iconStateClass,
175
+ disabled ? 'x-item-disabled' : '',
176
+ isPressed ? 'x-btn-pressed' : '',
177
+ hovered ? 'x-btn-over' : '',
178
+ focused ? 'x-btn-focus' : '',
179
+ clicked ? 'x-btn-click' : '',
180
+ // 拆分按钮箭头悬停(em 区)→ 箭头变橙(s-arrow-o.gif)
181
+ split && arrowHovered ? 'x-btn-menu-active' : '',
182
+ // 普通菜单按钮展开中 → menu-active
183
+ !split && menuOpen && menuItems ? 'x-btn-menu-active' : '',
184
+ ].filter(Boolean).join(' ');
185
+
186
+ const handleClick = useCallback(
187
+ (e: React.MouseEvent<HTMLButtonElement>) => {
188
+ if (disabled) return;
189
+ e.preventDefault();
190
+ // 拆分按钮:主体点击只走 handler / toggle,不展开菜单(等同 Ext.SplitButton.onClick 非箭头分支)
191
+ if (split) {
192
+ if (enableToggle) {
193
+ const next = !internalPressed;
194
+ setInternalPressed(next);
195
+ onToggle?.(next);
196
+ }
197
+ onClick?.(e);
198
+ handler?.();
199
+ return;
200
+ }
201
+ // 普通菜单按钮:点击只切换菜单,不触发 handler(等同 Ext)
202
+ if (menuItems) {
203
+ setMenuOpen((o) => !o);
204
+ onClick?.(e);
205
+ return;
206
+ }
207
+ if (enableToggle) {
208
+ const next = !internalPressed;
209
+ setInternalPressed(next);
210
+ onToggle?.(next);
211
+ onClick?.(e);
212
+ handler?.();
213
+ return;
214
+ }
215
+ onClick?.(e);
216
+ handler?.();
217
+ },
218
+ [disabled, split, menuItems, enableToggle, onClick, handler, internalPressed, onToggle],
219
+ );
220
+
221
+ // 拆分按钮箭头区点击(Ext.isClickOnArrow:target 为 em 即箭头 padding 区)
222
+ const handleEmClick = useCallback(
223
+ (e: React.MouseEvent<HTMLElement>) => {
224
+ if (disabled) return;
225
+ if ((e.target as HTMLElement).tagName !== 'EM') return; // 点击主体 button 交给 handleClick
226
+ e.preventDefault();
227
+ if (menuItems) setMenuOpen(true); // 箭头点击展开菜单(已开则保持,等同 Ext)
228
+ onClick?.(e as unknown as React.MouseEvent<HTMLButtonElement>);
229
+ arrowHandler?.();
230
+ },
231
+ [disabled, menuItems, onClick, arrowHandler],
232
+ );
233
+
234
+ return (
235
+ <span ref={wrapRef} style={{ position: 'relative', display: 'block', ...style }}>
236
+ <table cellSpacing={0} className={tableClass} style={{ ...(minWidth ? { minWidth } : {}), ...(width ? { width: `${width}px` } : {}) }}>
237
+ <tbody className={`x-btn-${scale} x-btn-icon-${scale}-${iconAlign}`}>
238
+ <tr>
239
+ <td className="x-btn-tl"><i>&#160;</i></td>
240
+ <td className="x-btn-tc"></td>
241
+ <td className="x-btn-tr"><i>&#160;</i></td>
242
+ </tr>
243
+ <tr>
244
+ <td className="x-btn-ml"><i>&#160;</i></td>
245
+ <td className="x-btn-mc">
246
+ <em
247
+ className={emClass}
248
+ unselectable="on"
249
+ title={split ? arrowTooltip : undefined}
250
+ onClick={split ? handleEmClick : undefined}
251
+ onMouseEnter={() => !disabled && setArrowHovered(true)}
252
+ onMouseLeave={() => !disabled && setArrowHovered(false)}
253
+ >
254
+ <button
255
+ ref={buttonRef}
256
+ type={type}
257
+ className={['x-btn-text', iconCls].filter(Boolean).join(' ')}
258
+ style={{
259
+ ...(icon ? { backgroundImage: `url(${icon})` } : {}),
260
+ ...(icon || iconCls ? {} : { padding: 0 }),
261
+ }}
262
+ onClick={handleClick}
263
+ onMouseEnter={() => !disabled && setHovered(true)}
264
+ onMouseLeave={() => !disabled && setHovered(false)}
265
+ onFocus={() => !disabled && setFocused(true)}
266
+ onBlur={() => !disabled && setFocused(false)}
267
+ onMouseDown={() => !disabled && setClicked(true)}
268
+ onMouseUp={() => !disabled && setClicked(false)}
269
+ >{text || ' '}</button>
270
+ </em>
271
+ </td>
272
+ <td className="x-btn-mr"><i>&#160;</i></td>
273
+ </tr>
274
+ <tr>
275
+ <td className="x-btn-bl"><i>&#160;</i></td>
276
+ <td className="x-btn-bc"></td>
277
+ <td className="x-btn-br"><i>&#160;</i></td>
278
+ </tr>
279
+ </tbody>
280
+ </table>
281
+ {menuOpen && menuItems && menuPos && createPortal(
282
+ <div
283
+ ref={menuElRef}
284
+ style={{ position: 'fixed', top: menuPos.top, left: menuPos.left, zIndex: 15000 }}
285
+ >
286
+ <Menu
287
+ items={menuItems}
288
+ onItemClick={(item, index) => {
289
+ onMenuItemClick?.(item, index);
290
+ setMenuOpen(false);
291
+ }}
292
+ />
293
+ </div>,
294
+ document.body,
295
+ )}
296
+ </span>
297
+ );
298
+ }
299
+
300
+ export default Button;