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.
- package/LICENSE +21 -0
- package/package.json +36 -0
- package/src/components/AbsoluteLayout.tsx +206 -0
- package/src/components/Accordion.tsx +266 -0
- package/src/components/AnchorLayout.tsx +131 -0
- package/src/components/BorderLayout.tsx +512 -0
- package/src/components/Box.tsx +289 -0
- package/src/components/Button.tsx +300 -0
- package/src/components/ButtonGroup.tsx +170 -0
- package/src/components/CalendarPicker.tsx +431 -0
- package/src/components/CardLayout.tsx +122 -0
- package/src/components/CenterLayout.tsx +95 -0
- package/src/components/CheckboxGroup.tsx +200 -0
- package/src/components/ColorMenu.tsx +195 -0
- package/src/components/ColorPalette.tsx +126 -0
- package/src/components/ColumnLayout.tsx +183 -0
- package/src/components/ColumnTree.tsx +383 -0
- package/src/components/CycleButton.tsx +302 -0
- package/src/components/DataView.tsx +244 -0
- package/src/components/DataViewMore.tsx +337 -0
- package/src/components/DataViewTransition.tsx +235 -0
- package/src/components/DateMenu.tsx +163 -0
- package/src/components/DatePicker.tsx +387 -0
- package/src/components/DisplayField.tsx +73 -0
- package/src/components/Editor.tsx +214 -0
- package/src/components/EditorGrid.tsx +361 -0
- package/src/components/FieldLabeler.tsx +81 -0
- package/src/components/FieldReplicator.tsx +135 -0
- package/src/components/FieldSet.tsx +149 -0
- package/src/components/FitLayout.tsx +76 -0
- package/src/components/Form.tsx +3484 -0
- package/src/components/Grid.tsx +2173 -0
- package/src/components/GridFilters.tsx +332 -0
- package/src/components/GroupTab.tsx +274 -0
- package/src/components/HtmlEditor.tsx +391 -0
- package/src/components/ImageOrganizer.tsx +984 -0
- package/src/components/ImageOrganizerService.ts +361 -0
- package/src/components/ListView.tsx +221 -0
- package/src/components/LoadMask.tsx +74 -0
- package/src/components/Menu.tsx +227 -0
- package/src/components/MessageBox.tsx +138 -0
- package/src/components/MultiSlider.tsx +369 -0
- package/src/components/Organizer.tsx +403 -0
- package/src/components/PagingToolbar.tsx +255 -0
- package/src/components/Panel.tsx +451 -0
- package/src/components/ProgressBar.tsx +127 -0
- package/src/components/PropertyGrid.tsx +142 -0
- package/src/components/QuickTip.tsx +174 -0
- package/src/components/Resizable.tsx +241 -0
- package/src/components/RowLayout.tsx +126 -0
- package/src/components/Slider.tsx +353 -0
- package/src/components/SliderField.tsx +206 -0
- package/src/components/SpinnerField.tsx +141 -0
- package/src/components/SplitBar.tsx +215 -0
- package/src/components/Spotlight.tsx +262 -0
- package/src/components/StatusBar.tsx +118 -0
- package/src/components/TabPanel.tsx +540 -0
- package/src/components/TableLayout.tsx +132 -0
- package/src/components/ThemeSwitcher.tsx +167 -0
- package/src/components/ToolTip.tsx +226 -0
- package/src/components/Toolbar.tsx +321 -0
- package/src/components/Tree.tsx +809 -0
- package/src/components/TreeGrid.tsx +418 -0
- package/src/components/Viewport.tsx +218 -0
- package/src/components/Window.tsx +441 -0
- package/src/components/Writer.tsx +303 -0
- package/src/components/XmlTreeLoader.tsx +233 -0
- package/src/components/dnd/DndContext.tsx +328 -0
- package/src/components/dnd/DragSource.tsx +202 -0
- package/src/components/dnd/DragZone.tsx +66 -0
- package/src/components/dnd/DropTarget.tsx +86 -0
- package/src/components/dnd/DropZone.tsx +166 -0
- package/src/components/dnd/PanelProxy.tsx +81 -0
- package/src/components/dnd/PanelResizer.tsx +143 -0
- package/src/components/dnd/Portal.tsx +254 -0
- package/src/components/dnd/Reorderer.tsx +73 -0
- package/src/components/dnd/ToolbarReorderer.tsx +226 -0
- package/src/main.tsx +2649 -0
- package/src/styles/ext-theme.css +889 -0
- package/src/util/theme.ts +95 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hdl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-extjs",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Pixel-faithful React+TypeScript reimplementation of Ext JS 3.2.1 UI (charts excluded).",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"main": "src/main.tsx",
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "vite",
|
|
15
|
+
"build": "tsc && vite build",
|
|
16
|
+
"preview": "vite preview",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"test:visual": "playwright test",
|
|
19
|
+
"docs": "vite --config docs/vite.docs.config.ts",
|
|
20
|
+
"docs:build": "vite build --config docs/vite.docs.config.ts"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"react": "^18.3.1",
|
|
24
|
+
"react-dom": "^18.3.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/react": "^18.3.3",
|
|
28
|
+
"@types/react-dom": "^18.3.0",
|
|
29
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
30
|
+
"typescript": "^5.5.4",
|
|
31
|
+
"vite": "^5.4.0",
|
|
32
|
+
"@playwright/test": "^1.46.0",
|
|
33
|
+
"pixelmatch": "^5.3.0",
|
|
34
|
+
"pngjs": "^7.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface AbsItem {
|
|
4
|
+
/** 面板标题(渲染 x-panel-header)。省略则无 header、整高即 body。 */
|
|
5
|
+
title?: string;
|
|
6
|
+
/** 主体内容(纯文本/HTML 字符串);也可通过 children 渲染任意 React 节点。 */
|
|
7
|
+
html?: string;
|
|
8
|
+
children?: React.ReactNode;
|
|
9
|
+
/** 绝对定位 left(px),对齐 Ext 组件级 x 配置(onRender 阶段 setPosition(x,y))。省略默认 0。 */
|
|
10
|
+
x?: number;
|
|
11
|
+
/** 绝对定位 top(px),对齐 Ext 组件级 y 配置。省略默认 0。 */
|
|
12
|
+
y?: number;
|
|
13
|
+
/** 锚点字符串(对齐 Ext.layout.AnchorLayout 的 anchor),如 '100%' / '100% 100%' / '-20 50%'。
|
|
14
|
+
* 与 x/y 叠加使用:先按容器尺寸算出宽高,再减去子项已定位的 x/y 偏移(adjustWidthAnchor/adjustHeightAnchor)。 */
|
|
15
|
+
anchor?: string;
|
|
16
|
+
/** 固定宽(px)。与 anchor 宽度段二选一;都给时 anchor 优先级。 */
|
|
17
|
+
width?: number;
|
|
18
|
+
/** 固定高(px)。与 anchor 高度段二选一;都给时 anchor 优先级。 */
|
|
19
|
+
height?: number;
|
|
20
|
+
/** 透传到 .x-panel-body 的 inline style */
|
|
21
|
+
bodyStyle?: React.CSSProperties;
|
|
22
|
+
/** 追加在子项 .x-panel 上的 class */
|
|
23
|
+
cls?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AbsoluteLayoutProps {
|
|
27
|
+
/** 容器总宽(px),对齐 Ext.Container width,也是 anchor 百分比基准。 */
|
|
28
|
+
width?: number;
|
|
29
|
+
/** 容器总高(px),对齐 Ext.Container height,也是 anchor 百分比基准。 */
|
|
30
|
+
height?: number;
|
|
31
|
+
items: AbsItem[];
|
|
32
|
+
/** 数据测试 id,挂在根容器上。 */
|
|
33
|
+
testId?: string;
|
|
34
|
+
style?: React.CSSProperties;
|
|
35
|
+
className?: string;
|
|
36
|
+
/** 容器四边内边距(px 或 CSS 简写)。影响 anchor 调整(同 Ext target.getPadding)。默认 0。 */
|
|
37
|
+
padding?: number | string;
|
|
38
|
+
/** 子项附加类(替换默认 'x-abs-layout-item' 时传入,新值须自行包含该类名)。默认 'x-abs-layout-item'。 */
|
|
39
|
+
extraCls?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Ext 3.2.1 实测:x-panel 本身 border 为 0,header 高 26px,body 占剩余(含 1px 底边框,border-box)。
|
|
43
|
+
const HEADER_H = 26;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 解析单段锚点值(移植 Ext.layout.AnchorLayout.parseAnchor):
|
|
47
|
+
* - 'right'/'r'/'bottom'/'b':侧锚点,返回 v - (cstart - start);
|
|
48
|
+
* - '%' 结尾:相对容器尺寸的百分比(向下取整);
|
|
49
|
+
* - 纯数字(如 '-20'):相对容器尺寸的偏移(v + n,负数即留白);
|
|
50
|
+
* - 空 / 'none' / 非法:返回 false(不计算该轴)。
|
|
51
|
+
* 注:原版带 last 缓存用于避免重复测量,本复刻为纯函数直接计算,无副作用。
|
|
52
|
+
*/
|
|
53
|
+
function parseAnchor(
|
|
54
|
+
a: string | undefined,
|
|
55
|
+
start?: number,
|
|
56
|
+
cstart?: number,
|
|
57
|
+
): ((v: number) => number) | false {
|
|
58
|
+
if (a && a !== 'none') {
|
|
59
|
+
if (/^(r|right|b|bottom)$/i.test(a)) {
|
|
60
|
+
const diff = (cstart ?? 0) - (start ?? 0);
|
|
61
|
+
return (v: number) => v - diff;
|
|
62
|
+
} else if (a.indexOf('%') !== -1) {
|
|
63
|
+
const ratio = parseFloat(a.replace('%', '')) * 0.01;
|
|
64
|
+
return (v: number) => Math.floor(v * ratio);
|
|
65
|
+
} else {
|
|
66
|
+
const n = parseInt(a, 10);
|
|
67
|
+
if (!isNaN(n)) return (v: number) => v + n;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 解析 padding 简写("t r b l"),返回 top/left 用于 anchor 偏移调整。 */
|
|
74
|
+
function parsePadding(p: number | string = 0): { top: number; left: number } {
|
|
75
|
+
if (typeof p === 'number') return { top: p, left: p };
|
|
76
|
+
const v = p.trim().split(/\s+/).map(Number);
|
|
77
|
+
const top = v[0] ?? 0;
|
|
78
|
+
const right = v[1] != null ? v[1] : top;
|
|
79
|
+
const left = v[3] != null ? v[3] : right;
|
|
80
|
+
return { top, left };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 手写面板,精确对齐 Ext.Container(absolute) 实测 DOM:
|
|
84
|
+
// .x-panel(含 extraCls=x-abs-layout-item,position:absolute,left:x/top:y)
|
|
85
|
+
// > .x-panel-header + .x-panel-bwrap > .x-panel-body
|
|
86
|
+
// ext-all.css:4852 定义 .x-abs-layout-item{position:absolute;left:0;top:0},内联 left/top 覆盖默认 0。
|
|
87
|
+
function AbsPanel({
|
|
88
|
+
title,
|
|
89
|
+
cls = '',
|
|
90
|
+
x = 0,
|
|
91
|
+
y = 0,
|
|
92
|
+
width,
|
|
93
|
+
height,
|
|
94
|
+
html,
|
|
95
|
+
children,
|
|
96
|
+
bodyStyle,
|
|
97
|
+
}: {
|
|
98
|
+
title?: string;
|
|
99
|
+
cls?: string;
|
|
100
|
+
x?: number;
|
|
101
|
+
y?: number;
|
|
102
|
+
width?: number;
|
|
103
|
+
height?: number;
|
|
104
|
+
html?: string;
|
|
105
|
+
children?: React.ReactNode;
|
|
106
|
+
bodyStyle?: React.CSSProperties;
|
|
107
|
+
}) {
|
|
108
|
+
const headerH = title ? HEADER_H : 0;
|
|
109
|
+
const bodyH = height != null ? height - headerH : undefined;
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
className={['x-panel', cls].filter(Boolean).join(' ')}
|
|
113
|
+
style={{ position: 'absolute', left: x, top: y, width, height }}
|
|
114
|
+
>
|
|
115
|
+
{title && (
|
|
116
|
+
<div className="x-panel-header x-unselectable" unselectable="on">
|
|
117
|
+
<span className="x-panel-header-text">{title}</span>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
<div className="x-panel-bwrap">
|
|
121
|
+
<div
|
|
122
|
+
className={['x-panel-body', title ? '' : 'x-panel-body-noheader'].filter(Boolean).join(' ')}
|
|
123
|
+
style={{ height: bodyH, ...bodyStyle }}
|
|
124
|
+
>
|
|
125
|
+
{html != null ? html : children}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 复刻 Ext.layout.AbsoluteLayout(Ext 3.2.1,继承 AnchorLayout)。
|
|
134
|
+
*
|
|
135
|
+
* 行为逐行移植 AbsoluteLayout.js + AnchorLayout.js:
|
|
136
|
+
* - onLayout:target.position() → 容器根 position:relative(ext-all.css 无专属 ct 类,
|
|
137
|
+
* 与 AnchorLayout 一致用自定义 hook 类 'abs-layout-root');
|
|
138
|
+
* - extraCls 默认 'x-abs-layout-item',追加到每个子项元素(ext-all.css:4852 将其设为
|
|
139
|
+
* position:absolute,左/上默认 0,由内联 left/top 覆盖到 x/y);
|
|
140
|
+
* - 子项以绝对定位放置于 (x, y),x/y 即 Ext 组件级 x/y 配置(onRender 阶段 setPosition);
|
|
141
|
+
* - 子项可叠加 anchor(继承 AnchorLayout):先按容器尺寸算宽高,再经 adjustWidthAnchor/
|
|
142
|
+
* adjustHeightAnchor 减去子项已定位的 x/y 偏移(comp.getPosition(true))再加容器
|
|
143
|
+
* paddingLeft/paddingTop,从而让 anchor 相对"容器剩余可用区"而非整容器计算。
|
|
144
|
+
*
|
|
145
|
+
* 定位数学(容器内容区 = width × height,padding 由 padding prop 控制):
|
|
146
|
+
* - 无 anchor:用 item.width/height(缺省则自适应内容);
|
|
147
|
+
* - 有 anchor:cw = aRight(W) - x + padL,ch = aBottom(H) - y + padT;
|
|
148
|
+
* - 例 {x:60,y:0,anchor:'100%'} → cw = W-60(不覆盖左侧 60px 标签区);
|
|
149
|
+
* - 例 {x:0,y:60,anchor:'100% 100%'} → cw = W, ch = H-60。
|
|
150
|
+
* 全部视觉来自 ext-all.css 既有规则(零新增 CSS)。
|
|
151
|
+
*/
|
|
152
|
+
export function AbsoluteLayout({
|
|
153
|
+
width = 0,
|
|
154
|
+
height = 0,
|
|
155
|
+
items,
|
|
156
|
+
testId,
|
|
157
|
+
style,
|
|
158
|
+
className = '',
|
|
159
|
+
padding = 0,
|
|
160
|
+
extraCls = 'x-abs-layout-item',
|
|
161
|
+
}: AbsoluteLayoutProps) {
|
|
162
|
+
const pad = parsePadding(padding);
|
|
163
|
+
|
|
164
|
+
const specs = items.map((it) => {
|
|
165
|
+
const cx = it.x ?? 0;
|
|
166
|
+
const cy = it.y ?? 0;
|
|
167
|
+
let cw = it.width;
|
|
168
|
+
let ch = it.height;
|
|
169
|
+
if (it.anchor) {
|
|
170
|
+
const vs = it.anchor.trim().split(/\s+/);
|
|
171
|
+
const aRight = parseAnchor(vs[0], it.width, width);
|
|
172
|
+
const aBottom = parseAnchor(vs[1], it.height, height);
|
|
173
|
+
// 对齐 AnchorLayout.onLayout L161-162 + AbsoluteLayout.adjustWidthAnchor/adjustHeightAnchor:
|
|
174
|
+
// cw = a.right(W) - margins.lr - comp.getPosition(true)[0] + paddingLeft
|
|
175
|
+
if (aRight) cw = aRight(width) - cx + pad.left;
|
|
176
|
+
if (aBottom) ch = aBottom(height) - cy + pad.top;
|
|
177
|
+
}
|
|
178
|
+
return { it, cx, cy, cw, ch };
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
data-testid={testId}
|
|
184
|
+
className={['abs-layout-root', className].filter(Boolean).join(' ')}
|
|
185
|
+
style={{ position: 'relative', width, height, ...style }}
|
|
186
|
+
>
|
|
187
|
+
{specs.map((s, i) => (
|
|
188
|
+
<AbsPanel
|
|
189
|
+
key={i}
|
|
190
|
+
title={s.it.title}
|
|
191
|
+
cls={[extraCls, s.it.cls].filter(Boolean).join(' ')}
|
|
192
|
+
x={s.cx}
|
|
193
|
+
y={s.cy}
|
|
194
|
+
width={s.cw}
|
|
195
|
+
height={s.ch}
|
|
196
|
+
html={s.it.html}
|
|
197
|
+
bodyStyle={s.it.bodyStyle}
|
|
198
|
+
>
|
|
199
|
+
{s.it.children}
|
|
200
|
+
</AbsPanel>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default AbsoluteLayout;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import React, { forwardRef, useImperativeHandle, useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { Panel } from './Panel';
|
|
3
|
+
|
|
4
|
+
export interface AccordionItem {
|
|
5
|
+
title: string;
|
|
6
|
+
/** 直接 HTML 内容(对齐 Ext Panel.html 直渲) */
|
|
7
|
+
html?: string;
|
|
8
|
+
children?: React.ReactNode;
|
|
9
|
+
iconCls?: string;
|
|
10
|
+
/** 该子项展开时回调(对齐 Ext Panel 'expand' 事件) */
|
|
11
|
+
onExpand?: () => void;
|
|
12
|
+
/** 该子项折叠时回调(对齐 Ext Panel 'collapse' 事件) */
|
|
13
|
+
onCollapse?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AccordionProps {
|
|
17
|
+
/** 根面板标题(可选) */
|
|
18
|
+
title?: string;
|
|
19
|
+
width?: number | string;
|
|
20
|
+
/** 固定高度(fill 模式需要);不传则 autoHeight */
|
|
21
|
+
height?: number;
|
|
22
|
+
items: AccordionItem[];
|
|
23
|
+
/** 初始激活项索引(非受控,默认 0 即首项展开;传 null 表示初始全部折叠) */
|
|
24
|
+
defaultActiveIndex?: number | null;
|
|
25
|
+
/** 受控激活项索引;提供后由外部控制(单开互斥模式) */
|
|
26
|
+
activeIndex?: number | null;
|
|
27
|
+
/** 展开项填充剩余空间(Ext fill,默认 true);false 时按内容自适应高度 */
|
|
28
|
+
fill?: boolean;
|
|
29
|
+
/** 点击标题即展开/折叠(Ext titleCollapse,默认 true) */
|
|
30
|
+
titleCollapse?: boolean;
|
|
31
|
+
/** 隐藏折叠/展开箭头工具,仅标题点击(Ext hideCollapseTool,默认 false) */
|
|
32
|
+
hideCollapseTool?: boolean;
|
|
33
|
+
/** toggle 工具相对自定义 tools 的顺序(Ext collapseFirst,默认 true) */
|
|
34
|
+
collapseFirst?: boolean;
|
|
35
|
+
/** 展开/折叠高度过渡(Ext animate,默认 true;CSS 高度过渡,最终态与 golden 像素一致) */
|
|
36
|
+
animate?: boolean;
|
|
37
|
+
/** 展开项移到顶部(Ext activeOnTop,默认 false) */
|
|
38
|
+
activeOnTop?: boolean;
|
|
39
|
+
/** 容器宽度自适应内容(Ext autoWidth,默认 false) */
|
|
40
|
+
autoWidth?: boolean;
|
|
41
|
+
/** 激活项变更回调(index 为当前激活项,全部折叠时为 null) */
|
|
42
|
+
onActiveChange?: (index: number | null) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 运行时句柄(对齐 Ext.Container 动态增删 + AccordionLayout 互斥/多开展开):
|
|
47
|
+
* - addItem(item, index?):在末尾(或指定位置)插入子面板
|
|
48
|
+
* - removeItem(index):删除指定子面板(自动重排其余索引)
|
|
49
|
+
* - updateItem(index, partial):就地更新子面板(标题/内容)
|
|
50
|
+
* - getItems():读当前子面板数组
|
|
51
|
+
* - setActive(index):编程式展开指定项(仅非受控时生效)
|
|
52
|
+
* - getActive():读当前激活索引(全部折叠返回 -1)
|
|
53
|
+
*/
|
|
54
|
+
export interface AccordionHandle {
|
|
55
|
+
addItem: (item: AccordionItem, index?: number) => void;
|
|
56
|
+
removeItem: (index: number) => void;
|
|
57
|
+
updateItem: (index: number, partial: Partial<AccordionItem>) => void;
|
|
58
|
+
getItems: () => AccordionItem[];
|
|
59
|
+
setActive: (index: number) => void;
|
|
60
|
+
getActive: () => number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 以下高度常量对照当前主题 CSS 实测 DOM 回填(见 _acc_measure 调试脚本)。
|
|
64
|
+
// 根面板为普通非 frame header(当前主题 .x-panel-header{padding:5px 8px;border:1px} → 高 27);
|
|
65
|
+
// 子面板 header 为 .x-accordion-hd(当前主题实测 高 26,border:false 仅保留 1px 下边框)。
|
|
66
|
+
// fill 模式展开子 body 的 content 高度公式:
|
|
67
|
+
// expandedBodyContentH = height - ROOT_HEADER_H - 1 - CHILD_HEADER_H*N - 30
|
|
68
|
+
// 其中 1 = 根 body 底部 1px 边框占用;-30 = 2*15(子 body padding) + 0(子 body noborder 无边框占用)。
|
|
69
|
+
// 根面板 bodyPadding 已通过 bodyStyle padding:0 置 0(AccordionLayout 容器无内边距,section 贴边)。
|
|
70
|
+
// fill 时展开项占满容器可用高度(单开互斥,至多 1 个展开)。
|
|
71
|
+
const ROOT_HEADER_H = 27; // 当前主题下普通 .x-panel-header 实际高度
|
|
72
|
+
const CHILD_HEADER_H = 26; // 当前主题下 .x-accordion-hd 实际高度
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 保真映射(来源 Ext.layout.AccordionLayout + Ext.Panel):
|
|
76
|
+
* - 根 Panel 用普通 Panel(frame:false, border:true 默认),内部逐项渲染子 Panel。
|
|
77
|
+
* - 子 Panel:普通 x-panel(golden 实测 border 实际 0px,className 不含 noborder);header 加 x-accordion-hd;collapsible → 渲染 x-tool-toggle。
|
|
78
|
+
* - 互斥展开(单开):点击某 header(titleCollapse 默认 true)展开它并折叠其余;点击已展开项则全部折叠(对齐 Ext 允许全收起,Ext AccordionLayout.beforeExpand 展开时折叠上一个活动项)。
|
|
79
|
+
* - fill(默认 true):展开子 body 高度 = 容器 body 高 - 所有子 header 高之和(setItemSize 逻辑)。
|
|
80
|
+
* - 折叠子:collapse 时 body 隐藏(display:none,等效 golden 的 height:0+overflow:hidden,offset 均为 0),仅 header 可见。
|
|
81
|
+
* - activeOnTop:展开项冒泡到顶部(DOM 顺序重排),对齐 Ext。
|
|
82
|
+
* - hideCollapseTool:子 Panel 不渲染 x-tool-toggle,仅标题点击生效。
|
|
83
|
+
*/
|
|
84
|
+
export const Accordion = forwardRef<AccordionHandle, AccordionProps>(
|
|
85
|
+
(
|
|
86
|
+
{
|
|
87
|
+
title,
|
|
88
|
+
width,
|
|
89
|
+
height,
|
|
90
|
+
items,
|
|
91
|
+
defaultActiveIndex = 0,
|
|
92
|
+
activeIndex: controlled,
|
|
93
|
+
fill = true,
|
|
94
|
+
titleCollapse = true,
|
|
95
|
+
hideCollapseTool = false,
|
|
96
|
+
collapseFirst = true,
|
|
97
|
+
animate = true,
|
|
98
|
+
activeOnTop = false,
|
|
99
|
+
autoWidth = false,
|
|
100
|
+
onActiveChange,
|
|
101
|
+
}: AccordionProps,
|
|
102
|
+
ref,
|
|
103
|
+
) => {
|
|
104
|
+
// 单源数组法:items 为唯一真相,句柄直接基于 prev 纯函数改写
|
|
105
|
+
const [dynItems, setDynItems] = useState<AccordionItem[]>(() => items);
|
|
106
|
+
const itemsRef = useRef(dynItems);
|
|
107
|
+
itemsRef.current = dynItems;
|
|
108
|
+
|
|
109
|
+
// 展开项索引集合(单开互斥,至多 1 个;null 表示全部折叠)
|
|
110
|
+
const initActive = controlled !== undefined ? controlled : defaultActiveIndex;
|
|
111
|
+
const [expanded, setExpanded] = useState<number[]>(() =>
|
|
112
|
+
initActive == null ? [] : [initActive],
|
|
113
|
+
);
|
|
114
|
+
const expandedRef = useRef(expanded);
|
|
115
|
+
expandedRef.current = expanded;
|
|
116
|
+
|
|
117
|
+
// 展开/折叠变更时触发 per-item 事件与容器 onActiveChange(对齐 Ext Panel expand/collapse + 容器变更)
|
|
118
|
+
const onActiveChangeRef = useRef(onActiveChange);
|
|
119
|
+
onActiveChangeRef.current = onActiveChange;
|
|
120
|
+
const fireChanges = (prev: number[], next: number[]) => {
|
|
121
|
+
prev.forEach((i) => {
|
|
122
|
+
if (!next.includes(i)) itemsRef.current[i]?.onCollapse?.();
|
|
123
|
+
});
|
|
124
|
+
next.forEach((i) => {
|
|
125
|
+
if (!prev.includes(i)) itemsRef.current[i]?.onExpand?.();
|
|
126
|
+
});
|
|
127
|
+
const last = next.length ? next[next.length - 1] : null;
|
|
128
|
+
onActiveChangeRef.current?.(last);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// 受控激活索引同步(触发事件,但挂载首帧若与初始一致则不产生 diff)
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (controlled !== undefined) {
|
|
134
|
+
const next = controlled == null ? [] : [controlled];
|
|
135
|
+
fireChanges(expandedRef.current, next);
|
|
136
|
+
setExpanded(next);
|
|
137
|
+
}
|
|
138
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
139
|
+
}, [controlled]);
|
|
140
|
+
|
|
141
|
+
// 渲染顺序(activeOnTop 时展开项冒泡到顶)
|
|
142
|
+
const [order, setOrder] = useState<number[]>(() => dynItems.map((_, i) => i));
|
|
143
|
+
|
|
144
|
+
useImperativeHandle(
|
|
145
|
+
ref,
|
|
146
|
+
() => ({
|
|
147
|
+
addItem: (item: AccordionItem, index?: number) => {
|
|
148
|
+
setDynItems((prev) => {
|
|
149
|
+
const next = [...prev];
|
|
150
|
+
if (index === undefined || index >= next.length) next.push(item);
|
|
151
|
+
else next.splice(index, 0, item);
|
|
152
|
+
return next;
|
|
153
|
+
});
|
|
154
|
+
setOrder((o) => {
|
|
155
|
+
const ni = index === undefined || index >= dynItems.length ? dynItems.length : index;
|
|
156
|
+
return [...o.slice(0, ni), ni, ...o.slice(ni).map((x) => (x >= ni ? x + 1 : x))];
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
removeItem: (index: number) => {
|
|
160
|
+
setDynItems((prev) => prev.filter((_, i) => i !== index));
|
|
161
|
+
setExpanded((exp) => exp.filter((x) => x !== index).map((x) => (x > index ? x - 1 : x)));
|
|
162
|
+
setOrder((o) => o.filter((x) => x !== index).map((x) => (x > index ? x - 1 : x)));
|
|
163
|
+
},
|
|
164
|
+
updateItem: (index: number, partial: Partial<AccordionItem>) =>
|
|
165
|
+
setDynItems((prev) => prev.map((it, i) => (i === index ? { ...it, ...partial } : it))),
|
|
166
|
+
getItems: () => itemsRef.current,
|
|
167
|
+
setActive: (index: number) => {
|
|
168
|
+
if (controlled !== undefined) return;
|
|
169
|
+
const prev = expandedRef.current;
|
|
170
|
+
const next = [index];
|
|
171
|
+
fireChanges(prev, next);
|
|
172
|
+
setExpanded(next);
|
|
173
|
+
if (activeOnTop) setOrder((o) => [index, ...o.filter((x) => x !== index)]);
|
|
174
|
+
},
|
|
175
|
+
getActive: () => {
|
|
176
|
+
const e = expandedRef.current;
|
|
177
|
+
return e.length ? e[e.length - 1] : -1;
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
181
|
+
[activeOnTop, controlled],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
useEffect(() => setDynItems(items), [items]);
|
|
185
|
+
|
|
186
|
+
const toggle = (i: number) => {
|
|
187
|
+
if (controlled !== undefined) return;
|
|
188
|
+
const prev = expandedRef.current;
|
|
189
|
+
// 单开互斥(对齐 Ext AccordionLayout.beforeExpand:展开项时折叠其余):
|
|
190
|
+
// 点击已展开项 → 全部折叠(对齐 Ext 允许全收起);否则独占展开
|
|
191
|
+
const next = prev.includes(i) ? [] : [i];
|
|
192
|
+
fireChanges(prev, next);
|
|
193
|
+
setExpanded(next);
|
|
194
|
+
if (activeOnTop) setOrder((o) => [i, ...o.filter((x) => x !== i)]);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const hasRootTitle = !!title;
|
|
198
|
+
const rootHeaderH = hasRootTitle ? ROOT_HEADER_H : 0;
|
|
199
|
+
const expandedCount = expandedRef.current.length;
|
|
200
|
+
// 展开子 body 的 content 高度(content-box):对齐 golden setItemSize 计算。
|
|
201
|
+
// fill 时展开项占满容器可用高度(单开互斥,至多 1 个展开)。
|
|
202
|
+
const expandedBodyContentH =
|
|
203
|
+
height != null && fill
|
|
204
|
+
? Math.floor(
|
|
205
|
+
// 子面板为 noborder(border:false):child body border 占用 = 0,故 -30(2*childPadding15 + 0)
|
|
206
|
+
// 根面板 bodyPadding 已置 0(见下),不再额外扣减根 body padding。
|
|
207
|
+
(height - rootHeaderH - 1 - CHILD_HEADER_H * dynItems.length - 30) /
|
|
208
|
+
Math.max(expandedCount, 1),
|
|
209
|
+
)
|
|
210
|
+
: undefined;
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<Panel
|
|
214
|
+
title={title}
|
|
215
|
+
width={autoWidth ? undefined : width}
|
|
216
|
+
height={height}
|
|
217
|
+
bodyStyle={{ padding: 0 }}
|
|
218
|
+
>
|
|
219
|
+
{order.map((i) => {
|
|
220
|
+
const it = dynItems[i];
|
|
221
|
+
if (!it) return null;
|
|
222
|
+
const isExpanded = expanded.includes(i);
|
|
223
|
+
return (
|
|
224
|
+
<Panel
|
|
225
|
+
key={i}
|
|
226
|
+
title={it.title}
|
|
227
|
+
iconCls={it.iconCls}
|
|
228
|
+
border={false}
|
|
229
|
+
collapsible
|
|
230
|
+
collapsed={!isExpanded}
|
|
231
|
+
collapseControlled
|
|
232
|
+
titleCollapse={titleCollapse}
|
|
233
|
+
hideCollapseTool={hideCollapseTool}
|
|
234
|
+
collapseFirst={collapseFirst}
|
|
235
|
+
headerClassName="x-accordion-hd"
|
|
236
|
+
onHeaderClick={() => toggle(i)}
|
|
237
|
+
bodyStyle={{
|
|
238
|
+
padding: 15,
|
|
239
|
+
// 强制 content-box:对齐 ExtJS 3.2.1(golden content-box)。
|
|
240
|
+
// 否则宿主若有全局 border-box 重置,inline height 会被当成总高,
|
|
241
|
+
// 少渲染 2*padding(30)+border(1)=31px,导致根容器底部留白。
|
|
242
|
+
boxSizing: 'content-box',
|
|
243
|
+
...(isExpanded
|
|
244
|
+
? expandedBodyContentH != null
|
|
245
|
+
? { height: expandedBodyContentH }
|
|
246
|
+
: {}
|
|
247
|
+
: { display: 'none' }),
|
|
248
|
+
...(animate ? { transition: 'height 0.2s ease' } : {}),
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
{it.html !== undefined ? (
|
|
252
|
+
<span dangerouslySetInnerHTML={{ __html: it.html }} />
|
|
253
|
+
) : (
|
|
254
|
+
it.children
|
|
255
|
+
)}
|
|
256
|
+
</Panel>
|
|
257
|
+
);
|
|
258
|
+
})}
|
|
259
|
+
</Panel>
|
|
260
|
+
);
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
Accordion.displayName = 'Accordion';
|
|
265
|
+
|
|
266
|
+
export default Accordion;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface AnchorItem {
|
|
4
|
+
/** 面板标题(渲染 x-panel-header)。省略则无 header、整高即 body。 */
|
|
5
|
+
title?: string;
|
|
6
|
+
/** 主体内容(纯文本/HTML 字符串);也可通过 children 渲染任意 React 节点。 */
|
|
7
|
+
html?: string;
|
|
8
|
+
children?: React.ReactNode;
|
|
9
|
+
/** 锚点字符串,如 '100% 40%'(宽满 100%、高 40%)/ '-20 50%'(右侧留 20、高半)/ '50%'(仅宽,高自适应)。 */
|
|
10
|
+
anchor: string;
|
|
11
|
+
/** 透传到 .x-panel-body 的 inline style */
|
|
12
|
+
bodyStyle?: React.CSSProperties;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AnchorLayoutProps {
|
|
16
|
+
/** 容器总宽(px),对齐 Ext.Container width,也是 anchor 百分比基准。 */
|
|
17
|
+
width: number;
|
|
18
|
+
/** 容器总高(px),对齐 Ext.Container height,也是 anchor 百分比基准。 */
|
|
19
|
+
height: number;
|
|
20
|
+
items: AnchorItem[];
|
|
21
|
+
/** 数据测试 id,挂在根容器上。 */
|
|
22
|
+
testId?: string;
|
|
23
|
+
style?: React.CSSProperties;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Ext 3.2.1 实测:x-panel 本身 border 为 0,header 高 26px,body 占剩余(含 1px 底边框,border-box)。
|
|
28
|
+
const HEADER_H = 26;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 解析单段锚点值(Ext.layout.AnchorLayout.parseAnchor / adjustWidthAnchor / adjustHeightAnchor 移植):
|
|
32
|
+
* - '%' 结尾:相对容器尺寸的百分比(四舍五入);
|
|
33
|
+
* - '-N':容器尺寸减去 N(右侧/底部留白);
|
|
34
|
+
* - 纯数字:绝对像素;
|
|
35
|
+
* - 空:容器尺寸(铺满)。
|
|
36
|
+
*/
|
|
37
|
+
function parseAnchorVal(s: string, containerSize: number): number {
|
|
38
|
+
s = (s || '').trim();
|
|
39
|
+
if (!s) return containerSize;
|
|
40
|
+
if (s.endsWith('%')) return Math.round((parseFloat(s) / 100) * containerSize);
|
|
41
|
+
if (s.startsWith('-')) return containerSize + parseInt(s, 10);
|
|
42
|
+
const n = parseInt(s, 10);
|
|
43
|
+
if (!isNaN(n)) return n;
|
|
44
|
+
return containerSize;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 手写面板,精确对齐 Ext.Container(anchor) 实测 DOM:.x-panel > .x-panel-header + .x-panel-bwrap > .x-panel-body
|
|
48
|
+
function AnchorPanel({
|
|
49
|
+
title,
|
|
50
|
+
width,
|
|
51
|
+
height,
|
|
52
|
+
html,
|
|
53
|
+
children,
|
|
54
|
+
bodyStyle,
|
|
55
|
+
}: {
|
|
56
|
+
title?: string;
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
html?: string;
|
|
60
|
+
children?: React.ReactNode;
|
|
61
|
+
bodyStyle?: React.CSSProperties;
|
|
62
|
+
}) {
|
|
63
|
+
const headerH = title ? HEADER_H : 0;
|
|
64
|
+
const bodyH = height - headerH;
|
|
65
|
+
return (
|
|
66
|
+
<div className="x-panel" style={{ width, height }}>
|
|
67
|
+
{title && (
|
|
68
|
+
<div className="x-panel-header x-unselectable" unselectable="on">
|
|
69
|
+
<span className="x-panel-header-text">{title}</span>
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
<div className="x-panel-bwrap">
|
|
73
|
+
<div className="x-panel-body" style={{ height: bodyH, ...bodyStyle }}>
|
|
74
|
+
{html != null ? html : children}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 复刻 Ext.layout.AnchorLayout(Ext 3.2.1)。
|
|
83
|
+
*
|
|
84
|
+
* DOM 对齐 ContainerLayout(anchor) 实测真值(#anchorlayout):
|
|
85
|
+
* - 容器根无专属 ct 类(ext-all.css 无 .x-anchor-layout-ct / .x-anchor-item);
|
|
86
|
+
* - 子面板直接作为容器子节点,block 流自上而下堆叠,各自按 anchor 计算固定 width/height。
|
|
87
|
+
*
|
|
88
|
+
* 定位数学(容器内容区 = width × height,无 padding):
|
|
89
|
+
* - width 段:'100%'→W、'50%'→W/2、'-20'→W-20、'200'→200、''→W;
|
|
90
|
+
* - height 段同理基于 H;
|
|
91
|
+
* - 面板整体高 = anchor 算出的 h,header 占 26px,body = h-26。
|
|
92
|
+
* 全部视觉来自 ext-all.css 既有规则(零新增 CSS)。
|
|
93
|
+
*/
|
|
94
|
+
export function AnchorLayout({
|
|
95
|
+
width,
|
|
96
|
+
height,
|
|
97
|
+
items,
|
|
98
|
+
testId,
|
|
99
|
+
style,
|
|
100
|
+
className = '',
|
|
101
|
+
}: AnchorLayoutProps) {
|
|
102
|
+
const rootStyle: React.CSSProperties = { width, height, ...style };
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
data-testid={testId}
|
|
107
|
+
className={['anchor-layout-root', className].filter(Boolean).join(' ')}
|
|
108
|
+
style={rootStyle}
|
|
109
|
+
>
|
|
110
|
+
{items.map((it, i) => {
|
|
111
|
+
const parts = (it.anchor || '').trim().split(/\s+/);
|
|
112
|
+
const w = parseAnchorVal(parts[0] ?? '', width);
|
|
113
|
+
const h = parseAnchorVal(parts[1] ?? '', height);
|
|
114
|
+
return (
|
|
115
|
+
<AnchorPanel
|
|
116
|
+
key={i}
|
|
117
|
+
title={it.title}
|
|
118
|
+
width={w}
|
|
119
|
+
height={h}
|
|
120
|
+
html={it.html}
|
|
121
|
+
bodyStyle={it.bodyStyle}
|
|
122
|
+
>
|
|
123
|
+
{it.children}
|
|
124
|
+
</AnchorPanel>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default AnchorLayout;
|