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
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ext.ux.grid.GridFilters 的保真复刻(本地过滤版)。
|
|
5
|
+
*
|
|
6
|
+
* 保真契约:
|
|
7
|
+
* - 类型/校验逻辑严格对齐 examples/ux/gridfilters/filter/*.js 的 validateRecord / getValue / isActivatable。
|
|
8
|
+
* - 控件 UI 复用原版 GridFilters.css / RangeMenu.css 的 iconCls(ux-rangemenu-gt/lt/eq、
|
|
9
|
+
* ux-gridfilter-text-icon)与对应 PNG 图片,不新增/不改写任何 CSS。
|
|
10
|
+
* - 通过 Grid 列定义上的 `filter` 配置驱动(对齐 Ext GridFilters.addFilters 读取 ColumnModel
|
|
11
|
+
* 上 filter 属性的列定义式用法);type 缺省时按首条数据值推断(对齐 filter.type || field.type)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type GridFilterType = 'string' | 'numeric' | 'date' | 'list' | 'boolean';
|
|
15
|
+
|
|
16
|
+
export interface GridFilterConfig {
|
|
17
|
+
/** 过滤器类型;省略时按数据推断。 */
|
|
18
|
+
type?: GridFilterType;
|
|
19
|
+
/** 目标字段(通常与列 dataIndex 一致;列定义式下自动取列 dataIndex)。 */
|
|
20
|
+
dataIndex?: string;
|
|
21
|
+
/** 初始值。 */
|
|
22
|
+
value?: any;
|
|
23
|
+
/** 初始是否激活。 */
|
|
24
|
+
active?: boolean;
|
|
25
|
+
// string 专用
|
|
26
|
+
/** iconCls,默认 ux-gridfilter-text-icon。 */
|
|
27
|
+
iconCls?: string;
|
|
28
|
+
// numeric / date 专用:比较子项顺序('-' 为分隔线)
|
|
29
|
+
menuItems?: string[];
|
|
30
|
+
// date 专用
|
|
31
|
+
dateFormat?: string;
|
|
32
|
+
beforeText?: string;
|
|
33
|
+
afterText?: string;
|
|
34
|
+
onText?: string;
|
|
35
|
+
// list 专用:本地选项(数组,元素可为 字符串 | [id,label] | {id,label/name/text})
|
|
36
|
+
options?: any[];
|
|
37
|
+
phpMode?: boolean;
|
|
38
|
+
// boolean 专用
|
|
39
|
+
yesText?: string;
|
|
40
|
+
noText?: string;
|
|
41
|
+
defaultValue?: boolean | null;
|
|
42
|
+
[k: string]: any;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const BLANK = '/extjs-assets/images/default/s.gif';
|
|
46
|
+
|
|
47
|
+
/** 按首条数据值推断过滤器类型(对齐 Ext filter.type || store.fields.get(dI).type)。 */
|
|
48
|
+
export function inferFilterType(data: any[], dataIndex: string): GridFilterType {
|
|
49
|
+
const vals = (data || [])
|
|
50
|
+
.map((r) => r[dataIndex])
|
|
51
|
+
.filter((v) => v !== undefined && v !== null && v !== '');
|
|
52
|
+
if (vals.length === 0) return 'string';
|
|
53
|
+
if (vals.every((v) => typeof v === 'boolean')) return 'boolean';
|
|
54
|
+
if (vals.every((v) => typeof v === 'number')) return 'numeric';
|
|
55
|
+
if (vals.every((v) => v instanceof Date)) return 'date';
|
|
56
|
+
if (
|
|
57
|
+
typeof vals[0] === 'string' &&
|
|
58
|
+
vals.every((v) => /^\d{4}-\d{2}-\d{2}/.test(String(v)))
|
|
59
|
+
) {
|
|
60
|
+
return 'date';
|
|
61
|
+
}
|
|
62
|
+
return 'string';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 各类型的空值初始态。 */
|
|
66
|
+
export function defaultEmpty(def: GridFilterConfig): any {
|
|
67
|
+
switch (def.type) {
|
|
68
|
+
case 'numeric':
|
|
69
|
+
case 'date':
|
|
70
|
+
return {};
|
|
71
|
+
case 'list':
|
|
72
|
+
return [];
|
|
73
|
+
case 'boolean':
|
|
74
|
+
return def.defaultValue ?? null;
|
|
75
|
+
default:
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 是否具备激活条件(对齐各 Filter.isActivatable)。 */
|
|
81
|
+
export function isFilterActivatable(def: GridFilterConfig, value: any): boolean {
|
|
82
|
+
switch (def.type) {
|
|
83
|
+
case 'string':
|
|
84
|
+
return !!value && String(value).length > 0;
|
|
85
|
+
case 'numeric':
|
|
86
|
+
return (
|
|
87
|
+
!!value &&
|
|
88
|
+
Object.keys(value).some(
|
|
89
|
+
(k) => value[k] !== undefined && value[k] !== null && value[k] !== '',
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
case 'date':
|
|
93
|
+
return (
|
|
94
|
+
!!value &&
|
|
95
|
+
Object.keys(value).some((k) => !!value[k])
|
|
96
|
+
);
|
|
97
|
+
case 'list':
|
|
98
|
+
return !!value && Array.isArray(value) && value.length > 0;
|
|
99
|
+
case 'boolean':
|
|
100
|
+
return value !== undefined && value !== null;
|
|
101
|
+
default:
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function dayMs(s: string): number {
|
|
107
|
+
if (!s) return NaN;
|
|
108
|
+
// 按「日」比较:取前 10 位 YYYY-MM-DD,兼容 'YYYY-MM-DD' 与 'YYYY-MM-DD HH:MM:SS' 两种数据格式,
|
|
109
|
+
// 与原版 Ext DateFilter 仅按日期判定(忽略时分秒)一致。
|
|
110
|
+
const t = new Date(String(s).slice(0, 10) + 'T00:00:00').getTime();
|
|
111
|
+
return t;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 单条记录是否通过某过滤器(对齐各 Filter.validateRecord)。 */
|
|
115
|
+
export function validateFilter(def: GridFilterConfig, value: any, row: any): boolean {
|
|
116
|
+
const di = def.dataIndex as string;
|
|
117
|
+
const v = row[di];
|
|
118
|
+
switch (def.type) {
|
|
119
|
+
case 'string': {
|
|
120
|
+
const fv = (value as string) || '';
|
|
121
|
+
if (typeof v !== 'string') return fv.length === 0;
|
|
122
|
+
return v.toLowerCase().indexOf(fv.toLowerCase()) > -1;
|
|
123
|
+
}
|
|
124
|
+
case 'numeric': {
|
|
125
|
+
const val = v as number;
|
|
126
|
+
const fv = (value || {}) as Record<string, number | undefined>;
|
|
127
|
+
if (fv.eq !== undefined && fv.eq !== null && val != fv.eq) return false;
|
|
128
|
+
if (fv.lt !== undefined && fv.lt !== null && (val as number) >= fv.lt) return false;
|
|
129
|
+
if (fv.gt !== undefined && fv.gt !== null && (val as number) <= fv.gt) return false;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
case 'date': {
|
|
133
|
+
if (typeof v !== 'string' || !v) return false;
|
|
134
|
+
const t = dayMs(v);
|
|
135
|
+
if (isNaN(t)) return false;
|
|
136
|
+
const fv = (value || {}) as Record<string, string | undefined>;
|
|
137
|
+
if (fv.before && dayMs(fv.before) <= t) return false;
|
|
138
|
+
if (fv.after && dayMs(fv.after) >= t) return false;
|
|
139
|
+
if (fv.on && dayMs(fv.on) !== t) return false;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
case 'list': {
|
|
143
|
+
const sel = (value as any[]) || [];
|
|
144
|
+
return sel.indexOf(v) > -1;
|
|
145
|
+
}
|
|
146
|
+
case 'boolean': {
|
|
147
|
+
return v === !!value;
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 把 list 的 options 元素归一化为 [id, label]。 */
|
|
155
|
+
function normalizeOption(opt: any): [string, string] {
|
|
156
|
+
if (Array.isArray(opt)) return [String(opt[0]), String(opt[1])];
|
|
157
|
+
if (opt && typeof opt === 'object') {
|
|
158
|
+
const id = opt.id;
|
|
159
|
+
const label = opt.text ?? opt.name ?? opt.label ?? String(id);
|
|
160
|
+
return [String(id), String(label)];
|
|
161
|
+
}
|
|
162
|
+
return [String(opt), String(opt)];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface FilterWidgetProps {
|
|
166
|
+
def: GridFilterConfig;
|
|
167
|
+
value: any;
|
|
168
|
+
onChange: (value: any) => void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 过滤器子菜单控件(对齐 Ext.ux.menu.RangeMenu / ListMenu / DateFilter / StringFilter / BooleanFilter 的菜单体)。
|
|
173
|
+
* 通过 Menu 的 render 插槽承载,复用原版 iconCls 图片。
|
|
174
|
+
*/
|
|
175
|
+
export const FilterWidget: React.FC<FilterWidgetProps> = ({ def, value, onChange }) => {
|
|
176
|
+
const v = value ?? defaultEmpty(def);
|
|
177
|
+
|
|
178
|
+
if (def.type === 'string') {
|
|
179
|
+
return (
|
|
180
|
+
<div style={{ padding: '3px 6px', minWidth: 160 }}>
|
|
181
|
+
<input
|
|
182
|
+
type="text"
|
|
183
|
+
placeholder="Enter Filter Text..."
|
|
184
|
+
value={typeof v === 'string' ? v : ''}
|
|
185
|
+
onChange={(e) => onChange(e.target.value)}
|
|
186
|
+
style={{ width: '100%', boxSizing: 'border-box' }}
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (def.type === 'numeric') {
|
|
193
|
+
const items: Array<[string, string, string] | '-'> = [
|
|
194
|
+
['lt', def.beforeText ?? 'Less Than', 'ux-rangemenu-lt'],
|
|
195
|
+
['gt', def.afterText ?? 'Greater Than', 'ux-rangemenu-gt'],
|
|
196
|
+
'-',
|
|
197
|
+
['eq', def.onText ?? 'Equals', 'ux-rangemenu-eq'],
|
|
198
|
+
];
|
|
199
|
+
return (
|
|
200
|
+
<div style={{ padding: '3px 6px', minWidth: 170 }}>
|
|
201
|
+
{items.map((it, idx) =>
|
|
202
|
+
it === '-' ? (
|
|
203
|
+
<div key={`sep-${idx}`} className="x-menu-sep-li" style={{ margin: '2px 0' }}>
|
|
204
|
+
<span className="x-menu-sep" />
|
|
205
|
+
</div>
|
|
206
|
+
) : (
|
|
207
|
+
<div
|
|
208
|
+
key={it[0]}
|
|
209
|
+
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}
|
|
210
|
+
>
|
|
211
|
+
<img className={`x-menu-item-icon ${it[2]}`} src={BLANK} alt="" />
|
|
212
|
+
<input
|
|
213
|
+
type="number"
|
|
214
|
+
placeholder={it[1]}
|
|
215
|
+
value={v[it[0]] ?? ''}
|
|
216
|
+
onChange={(e) => {
|
|
217
|
+
const nv = { ...v };
|
|
218
|
+
const raw = e.target.value;
|
|
219
|
+
if (raw === '') delete nv[it[0]];
|
|
220
|
+
else nv[it[0]] = Number(raw);
|
|
221
|
+
onChange(nv);
|
|
222
|
+
}}
|
|
223
|
+
style={{ width: 110, boxSizing: 'border-box' }}
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
),
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (def.type === 'date') {
|
|
233
|
+
const fields: Array<[string, string, string]> = [
|
|
234
|
+
['before', def.beforeText ?? 'Before', 'ux-rangemenu-lt'],
|
|
235
|
+
['after', def.afterText ?? 'After', 'ux-rangemenu-gt'],
|
|
236
|
+
['on', def.onText ?? 'On', 'ux-rangemenu-eq'],
|
|
237
|
+
];
|
|
238
|
+
return (
|
|
239
|
+
<div style={{ padding: '3px 6px', minWidth: 180 }}>
|
|
240
|
+
{fields.map((f) => (
|
|
241
|
+
<div
|
|
242
|
+
key={f[0]}
|
|
243
|
+
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}
|
|
244
|
+
>
|
|
245
|
+
<img className={`x-menu-item-icon ${f[2]}`} src={BLANK} alt="" />
|
|
246
|
+
<span style={{ width: 48 }}>{f[1]}</span>
|
|
247
|
+
<input
|
|
248
|
+
type="date"
|
|
249
|
+
value={v[f[0]] ?? ''}
|
|
250
|
+
onChange={(e) => {
|
|
251
|
+
const nv = { ...v };
|
|
252
|
+
if (!e.target.value) delete nv[f[0]];
|
|
253
|
+
else nv[f[0]] = e.target.value;
|
|
254
|
+
onChange(nv);
|
|
255
|
+
}}
|
|
256
|
+
style={{ boxSizing: 'border-box' }}
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (def.type === 'list') {
|
|
265
|
+
const opts = (def.options || []).map(normalizeOption);
|
|
266
|
+
return (
|
|
267
|
+
<div style={{ padding: '2px 0', maxHeight: 220, overflow: 'auto', minWidth: 150 }}>
|
|
268
|
+
{opts.map(([id, label]) => {
|
|
269
|
+
const checked = Array.isArray(v) && v.indexOf(id) > -1;
|
|
270
|
+
return (
|
|
271
|
+
<div
|
|
272
|
+
key={id}
|
|
273
|
+
className={[
|
|
274
|
+
'x-menu-list-item',
|
|
275
|
+
'x-menu-item-check-item',
|
|
276
|
+
checked ? 'x-menu-item-checked' : '',
|
|
277
|
+
]
|
|
278
|
+
.filter(Boolean)
|
|
279
|
+
.join(' ')}
|
|
280
|
+
style={{ position: 'relative', cursor: 'pointer' }}
|
|
281
|
+
onClick={(e) => {
|
|
282
|
+
e.stopPropagation();
|
|
283
|
+
const set = new Set(Array.isArray(v) ? v : []);
|
|
284
|
+
if (set.has(id)) set.delete(id);
|
|
285
|
+
else set.add(id);
|
|
286
|
+
onChange([...set]);
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
{checked && <img className="x-menu-item-icon" src={BLANK} alt="" />}
|
|
290
|
+
<span className="x-menu-item-text">{label}</span>
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
})}
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// boolean
|
|
299
|
+
const yes = def.yesText ?? 'Yes';
|
|
300
|
+
const no = def.noText ?? 'No';
|
|
301
|
+
const cur = v === true;
|
|
302
|
+
const row = (label: string, val: boolean) => {
|
|
303
|
+
const checked = cur === val;
|
|
304
|
+
return (
|
|
305
|
+
<div
|
|
306
|
+
className={[
|
|
307
|
+
'x-menu-list-item',
|
|
308
|
+
'x-menu-item-check-item',
|
|
309
|
+
checked ? 'x-menu-item-checked' : '',
|
|
310
|
+
]
|
|
311
|
+
.filter(Boolean)
|
|
312
|
+
.join(' ')}
|
|
313
|
+
style={{ position: 'relative', cursor: 'pointer' }}
|
|
314
|
+
onClick={(e) => {
|
|
315
|
+
e.stopPropagation();
|
|
316
|
+
onChange(val);
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
{checked && <img className="x-menu-item-icon" src={BLANK} alt="" />}
|
|
320
|
+
<span className="x-menu-item-text">{label}</span>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
};
|
|
324
|
+
return (
|
|
325
|
+
<div style={{ padding: '2px 0', minWidth: 120 }}>
|
|
326
|
+
{row(yes, true)}
|
|
327
|
+
{row(no, false)}
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export default FilterWidget;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import React, { Children, isValidElement, useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GroupTab(等价 Ext.ux.GroupTabPanel / GroupTab)
|
|
5
|
+
* ------------------------------------------------------------------
|
|
6
|
+
* 分组标签:左侧分组条(每个分组含一个主标题 + 可展开的 sub-tab 子列表),
|
|
7
|
+
* 右侧 CardLayout 显示「当前激活分组」的「当前激活子页」。
|
|
8
|
+
*
|
|
9
|
+
* 交互完全由 mousedown / click 驱动(与 Ext 一致,本组件无拖拽):
|
|
10
|
+
* - 点击 expand 开关(a.x-grouptabs-expand) -> 展开/收起该分组的 sub 列表;
|
|
11
|
+
* - 点击分组主标题(非 sub、非 expand) -> 激活该分组并把内容定位到 mainItem;
|
|
12
|
+
* - 点击某个 sub 子页 -> 激活该分组并切换到对应子页。
|
|
13
|
+
*
|
|
14
|
+
* 注意:Ext 原文的 expand / 圆角 / joint 都依赖 gif 图片资源,这里用纯 CSS
|
|
15
|
+
* (+/- 伪元素、白边 joint)忠实还原视觉,不引入外部图片。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface GroupTabPageProps {
|
|
19
|
+
/** 子页 id(缺省自动生成) */
|
|
20
|
+
pageId?: string;
|
|
21
|
+
title: string;
|
|
22
|
+
/** 图标类(demo 中可自定义 CSS 着色),可选 */
|
|
23
|
+
iconCls?: string;
|
|
24
|
+
/** 悬停提示(等价 tabTip) */
|
|
25
|
+
tabTip?: string;
|
|
26
|
+
children?: React.ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 声明式占位:仅用于配置,真实渲染由 GroupTabPanel 接管 */
|
|
30
|
+
export const GroupTabPage: React.FC<GroupTabPageProps> = () => null;
|
|
31
|
+
|
|
32
|
+
export interface GroupTabProps {
|
|
33
|
+
/** 分组 id(缺省自动生成) */
|
|
34
|
+
groupId?: string;
|
|
35
|
+
title: string;
|
|
36
|
+
iconCls?: string;
|
|
37
|
+
/** 初始是否展开 sub 列表 */
|
|
38
|
+
expanded?: boolean;
|
|
39
|
+
/** 点击分组主标题时定位到的子页:索引或 pageId,默认 0 */
|
|
40
|
+
mainItem?: number | string;
|
|
41
|
+
/** 子页:GroupTabPage 列表 */
|
|
42
|
+
children?: React.ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 声明式占位:仅用于配置 */
|
|
46
|
+
export const GroupTab: React.FC<GroupTabProps> = () => null;
|
|
47
|
+
|
|
48
|
+
export interface GroupTabPanelProps {
|
|
49
|
+
/** 分组条位置,默认 left */
|
|
50
|
+
tabPosition?: 'left' | 'right';
|
|
51
|
+
/** 初始激活分组:索引或 groupId,默认第 0 个 */
|
|
52
|
+
activeGroup?: number | string;
|
|
53
|
+
/** 左侧分组条宽度(等价 Ext.TabPanel.tabWidth,原生默认 120) */
|
|
54
|
+
tabWidth?: number;
|
|
55
|
+
width?: number | string;
|
|
56
|
+
height?: number | string;
|
|
57
|
+
className?: string;
|
|
58
|
+
children?: React.ReactNode;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ParsedPage {
|
|
62
|
+
id: string;
|
|
63
|
+
title: string;
|
|
64
|
+
iconCls?: string;
|
|
65
|
+
tabTip?: string;
|
|
66
|
+
content: React.ReactNode;
|
|
67
|
+
}
|
|
68
|
+
interface ParsedGroup {
|
|
69
|
+
id: string;
|
|
70
|
+
title: string;
|
|
71
|
+
iconCls?: string;
|
|
72
|
+
expanded: boolean;
|
|
73
|
+
mainItem: string; // 解析后的 pageId
|
|
74
|
+
pages: ParsedPage[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseGroups(children: React.ReactNode): ParsedGroup[] {
|
|
78
|
+
const groups: ParsedGroup[] = [];
|
|
79
|
+
Children.forEach(children, (gc, gi) => {
|
|
80
|
+
if (!isValidElement(gc)) return;
|
|
81
|
+
if (gc.type !== GroupTab) return;
|
|
82
|
+
const gp = gc.props as GroupTabProps;
|
|
83
|
+
const gid = gp.groupId ?? `gt-${gi}`;
|
|
84
|
+
const pages: ParsedPage[] = [];
|
|
85
|
+
Children.forEach(gp.children, (pc, pi) => {
|
|
86
|
+
if (!isValidElement(pc)) return;
|
|
87
|
+
if (pc.type !== GroupTabPage) return;
|
|
88
|
+
const pp = pc.props as GroupTabPageProps;
|
|
89
|
+
pages.push({
|
|
90
|
+
id: pp.pageId ?? `pg-${gi}-${pi}`,
|
|
91
|
+
title: pp.title,
|
|
92
|
+
iconCls: pp.iconCls,
|
|
93
|
+
tabTip: pp.tabTip,
|
|
94
|
+
content: pp.children,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
let mainItem: string;
|
|
98
|
+
if (typeof gp.mainItem === 'number') {
|
|
99
|
+
mainItem = pages[gp.mainItem]?.id ?? pages[0]?.id ?? '';
|
|
100
|
+
} else if (typeof gp.mainItem === 'string') {
|
|
101
|
+
mainItem = pages.find((p) => p.id === gp.mainItem)?.id ?? pages[0]?.id ?? '';
|
|
102
|
+
} else {
|
|
103
|
+
mainItem = pages[0]?.id ?? '';
|
|
104
|
+
}
|
|
105
|
+
groups.push({
|
|
106
|
+
id: gid,
|
|
107
|
+
title: gp.title,
|
|
108
|
+
iconCls: gp.iconCls,
|
|
109
|
+
expanded: gp.expanded ?? false,
|
|
110
|
+
mainItem,
|
|
111
|
+
pages,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
return groups;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const GroupTabPanel: React.FC<GroupTabPanelProps> = ({
|
|
118
|
+
tabPosition = 'left',
|
|
119
|
+
activeGroup,
|
|
120
|
+
tabWidth = 120,
|
|
121
|
+
width,
|
|
122
|
+
height,
|
|
123
|
+
className,
|
|
124
|
+
children,
|
|
125
|
+
}) => {
|
|
126
|
+
const groups = useMemo(() => parseGroups(children), [children]);
|
|
127
|
+
|
|
128
|
+
const initGroupId = useMemo(() => {
|
|
129
|
+
if (typeof activeGroup === 'number') return groups[activeGroup]?.id ?? groups[0]?.id ?? '';
|
|
130
|
+
if (typeof activeGroup === 'string') {
|
|
131
|
+
return groups.find((g) => g.id === activeGroup)?.id ?? groups[0]?.id ?? '';
|
|
132
|
+
}
|
|
133
|
+
return groups[0]?.id ?? '';
|
|
134
|
+
}, [groups, activeGroup]);
|
|
135
|
+
|
|
136
|
+
const [activeGroupId, setActiveGroupId] = useState<string>(initGroupId);
|
|
137
|
+
const [expanded, setExpanded] = useState<Record<string, boolean>>(() => {
|
|
138
|
+
const m: Record<string, boolean> = {};
|
|
139
|
+
groups.forEach((g) => (m[g.id] = g.expanded));
|
|
140
|
+
return m;
|
|
141
|
+
});
|
|
142
|
+
const [activeTabByGroup, setActiveTabByGroup] = useState<Record<string, string>>(() => {
|
|
143
|
+
const m: Record<string, string> = {};
|
|
144
|
+
groups.forEach((g) => (m[g.id] = g.mainItem));
|
|
145
|
+
return m;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const activateGroup = (gid: string) => {
|
|
149
|
+
const g = groups.find((x) => x.id === gid);
|
|
150
|
+
if (!g) return;
|
|
151
|
+
setActiveGroupId(gid);
|
|
152
|
+
setActiveTabByGroup((prev) => ({ ...prev, [gid]: g.mainItem }));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const toggleGroup = (gid: string) =>
|
|
156
|
+
setExpanded((prev) => ({ ...prev, [gid]: !prev[gid] }));
|
|
157
|
+
|
|
158
|
+
const activatePage = (gid: string, pid: string) => {
|
|
159
|
+
setActiveGroupId(gid);
|
|
160
|
+
setActiveTabByGroup((prev) => ({ ...prev, [gid]: pid }));
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const activeGroupData = groups.find((g) => g.id === activeGroupId);
|
|
164
|
+
const activePage = activeGroupData?.pages.find(
|
|
165
|
+
(p) => p.id === activeTabByGroup[activeGroupId],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const wrapStyle: React.CSSProperties = {
|
|
169
|
+
width: width ?? '100%',
|
|
170
|
+
height: height ?? 320,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// 原生 GroupTab.css 采用 float 布局(.x-tab-panel-left .x-grouptabs-panel-header{float:left}
|
|
174
|
+
// / .x-grouptabs-bwrap{float:right}),其宽度本应由 Ext.TabPanel 的 JS 布局计算
|
|
175
|
+
// (GroupTabPanel.js: header.setWidth(tabWidth);adjustBodyWidth(w) => w - tabWidth)。
|
|
176
|
+
// 本 React 组件未复刻该 JS,故在此以组件内联方式补齐等价宽度,使原生 float 布局成立。
|
|
177
|
+
const headerStyle: React.CSSProperties = {
|
|
178
|
+
width: tabWidth,
|
|
179
|
+
height: '100%',
|
|
180
|
+
};
|
|
181
|
+
const bwrapStyle: React.CSSProperties = {
|
|
182
|
+
width: `calc(100% - ${tabWidth}px)`,
|
|
183
|
+
height: '100%',
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<div
|
|
188
|
+
className={[
|
|
189
|
+
'x-grouptabs-panel',
|
|
190
|
+
`x-tab-panel-${tabPosition}`,
|
|
191
|
+
'x-grouptabs-panel-' + tabPosition,
|
|
192
|
+
className,
|
|
193
|
+
]
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.join(' ')}
|
|
196
|
+
style={wrapStyle}
|
|
197
|
+
>
|
|
198
|
+
<div
|
|
199
|
+
className={`x-tab-panel-header x-grouptabs-panel-header x-tab-panel-header-${tabPosition}`}
|
|
200
|
+
style={headerStyle}
|
|
201
|
+
>
|
|
202
|
+
<div className="x-tab-strip-wrap" style={{ height: '100%', overflowY: 'auto', overflowX: 'hidden' }}>
|
|
203
|
+
<ul
|
|
204
|
+
className={`x-grouptabs-strip x-grouptabs-tab-strip-${tabPosition}`}
|
|
205
|
+
style={{ listStyle: 'none', margin: 0, padding: 0 }}
|
|
206
|
+
>
|
|
207
|
+
{groups.map((g, gi) => {
|
|
208
|
+
const isActiveGroup = g.id === activeGroupId;
|
|
209
|
+
const isExpanded = !!expanded[g.id];
|
|
210
|
+
const groupCls = [
|
|
211
|
+
'x-grouptabs-main',
|
|
212
|
+
gi === 0 ? 'x-tab-first' : '',
|
|
213
|
+
isActiveGroup ? 'x-grouptabs-strip-active' : '',
|
|
214
|
+
isExpanded ? 'x-grouptabs-expanded' : '',
|
|
215
|
+
]
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
.join(' ');
|
|
218
|
+
return (
|
|
219
|
+
<li key={g.id} className={groupCls} id={g.id}>
|
|
220
|
+
<a
|
|
221
|
+
className="x-grouptabs-expand"
|
|
222
|
+
onClick={() => toggleGroup(g.id)}
|
|
223
|
+
title={isExpanded ? '收起' : '展开'}
|
|
224
|
+
/>
|
|
225
|
+
<a
|
|
226
|
+
className={`x-grouptabs-text ${g.iconCls ?? ''}`}
|
|
227
|
+
onClick={() => activateGroup(g.id)}
|
|
228
|
+
>
|
|
229
|
+
{g.iconCls ? <span className={`x-grouptabs-icon ${g.iconCls}`} /> : null}
|
|
230
|
+
<span className="x-grouptabs-label">{g.title}</span>
|
|
231
|
+
</a>
|
|
232
|
+
{isExpanded && g.pages.length > 0 && (
|
|
233
|
+
<ul className="x-grouptabs-sub">
|
|
234
|
+
{g.pages.map((p) => {
|
|
235
|
+
const isActivePage = isActiveGroup && p.id === activeTabByGroup[g.id];
|
|
236
|
+
return (
|
|
237
|
+
<li
|
|
238
|
+
key={p.id}
|
|
239
|
+
className={isActivePage ? 'x-grouptabs-strip-active' : ''}
|
|
240
|
+
title={p.tabTip}
|
|
241
|
+
>
|
|
242
|
+
<a
|
|
243
|
+
className={`x-grouptabs-text ${p.iconCls ?? ''}`}
|
|
244
|
+
onClick={() => activatePage(g.id, p.id)}
|
|
245
|
+
>
|
|
246
|
+
{p.iconCls ? (
|
|
247
|
+
<span className={`x-grouptabs-icon ${p.iconCls}`} />
|
|
248
|
+
) : null}
|
|
249
|
+
<span className="x-grouptabs-label">{p.title}</span>
|
|
250
|
+
</a>
|
|
251
|
+
</li>
|
|
252
|
+
);
|
|
253
|
+
})}
|
|
254
|
+
</ul>
|
|
255
|
+
)}
|
|
256
|
+
</li>
|
|
257
|
+
);
|
|
258
|
+
})}
|
|
259
|
+
</ul>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
<div className="x-grouptabs-bwrap" style={bwrapStyle}>
|
|
263
|
+
<div
|
|
264
|
+
className={`x-tab-panel-body x-tab-panel-body-${tabPosition} x-grouptabs-panel-body`}
|
|
265
|
+
style={{ width: '100%', height: '100%', overflow: 'auto' }}
|
|
266
|
+
>
|
|
267
|
+
<div className="x-grouptab-page">{activePage ? activePage.content : null}</div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export default GroupTabPanel;
|