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,215 @@
1
+ import { useRef, useState, useEffect, type CSSProperties, type ReactNode, type MouseEvent as ReactMouseEvent } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+
4
+ // Ext.SplitBar 方位/朝向常量(对齐 src/widgets/SplitBar.js 顶部静态量)
5
+ const HORIZONTAL = 2; // Ext.SplitBar.HORIZONTAL
6
+ const VERTICAL = 1; // Ext.SplitBar.VERTICAL
7
+ const LEFT = 1; // Ext.SplitBar.LEFT
8
+ const RIGHT = 2; // Ext.SplitBar.RIGHT
9
+ const TOP = 3; // Ext.SplitBar.TOP
10
+ const BOTTOM = 4; // Ext.SplitBar.BOTTOM
11
+
12
+ export type SplitOrientation = 'horizontal' | 'vertical';
13
+ export type SplitPlacement = 'left' | 'right' | 'top' | 'bottom';
14
+
15
+ export interface SplitBarProps {
16
+ /** 分隔条朝向:'horizontal' 表示竖直条(拖拽改变宽度)/ 'vertical' 表示水平条(拖拽改变高度)。默认 horizontal。 */
17
+ orientation?: SplitOrientation;
18
+ /** 被缩放元素(resizingEl)相对分隔条的位置:left/right(水平)或 top/bottom(垂直)。默认 left。 */
19
+ placement?: SplitPlacement;
20
+ /** 被缩放元素的初始尺寸(水平=宽度 / 垂直=高度),对应 Ext 初始 getElementSize。 */
21
+ size?: number;
22
+ /** 最小尺寸(默认 0)。 */
23
+ minSize?: number;
24
+ /** 最大尺寸(默认 2000)。 */
25
+ maxSize?: number;
26
+ /** 水平模式下容器高度 / 垂直模式下容器宽度(交叉轴尺寸)。 */
27
+ thickness?: number;
28
+ /** 分隔条厚度(像素),默认 6。 */
29
+ splitterWidth?: number;
30
+ /** 被缩放区域的内容。 */
31
+ resizingContent?: ReactNode;
32
+ /** 被缩放区域的内联样式(不含尺寸,尺寸由组件控制)。 */
33
+ resizingStyle?: CSSProperties;
34
+ /** 分隔条的内联样式(不含 class 与尺寸,尺寸/背景由组件控制)。 */
35
+ splitterStyle?: CSSProperties;
36
+ /** 分隔条拖动后的回调,传入新尺寸。 */
37
+ onMoved?: (size: number) => void;
38
+ /** 拖拽开始前回调(对齐 Ext 'beforeresize' 事件)。 */
39
+ onBeforeResize?: () => void;
40
+ id?: string;
41
+ testId?: string;
42
+ }
43
+
44
+ const clamp = (v: number, min: number, max: number) => Math.min(Math.max(min, v), max);
45
+
46
+ /**
47
+ * 复刻 Ext.SplitBar(Ext 3.2.1,src/widgets/SplitBar.js)。
48
+ *
49
+ * DOM 契约(对齐原生 Ext.SplitBar,src/widgets/SplitBar.js + ext-all.css):
50
+ * - 分隔条(dragEl)根 class:`x-splitbar x-splitbar-h`(水平,竖直条)/ `x-splitbar x-splitbar-v`(垂直,水平条)。
51
+ * 原生 ext-all.css 仅赋予 cursor(col-resize/row-resize),无背景/grip——这是 standalone SplitBar 的真实原生外观。
52
+ * - 被缩放元素(resizingEl)渲染在分隔条一侧,尺寸由 size 态控制(原生 SplitBar 也不为此元素加额外 class)。
53
+ * - proxy(x-splitbar-proxy x-splitbar-proxy-{h|v})与 overlay(x-drag-overlay)经 portal 挂 document.body,
54
+ * 静态态 display:none,不参与容器截图(对齐 Ext 的 createProxy 行为)。
55
+ *
56
+ * 拖拽数学对齐 Ext.SplitBar.onEndProxyDrag:
57
+ * - 水平:newSize = startSize + (placement===LEFT ? dx : -dx)
58
+ * - 垂直:newSize = startSize + (placement===TOP ? dy : -dy)
59
+ * - 全程 Math.min/max 钳制到 [minSize, maxSize]。
60
+ */
61
+ export function SplitBar(props: SplitBarProps) {
62
+ const {
63
+ orientation = 'horizontal',
64
+ placement = 'left',
65
+ size = 160,
66
+ minSize = 0,
67
+ maxSize = 2000,
68
+ thickness = 120,
69
+ splitterWidth = 6,
70
+ resizingContent,
71
+ resizingStyle,
72
+ splitterStyle,
73
+ onMoved,
74
+ onBeforeResize,
75
+ id,
76
+ testId,
77
+ } = props;
78
+
79
+ const horizontal = orientation === 'horizontal';
80
+ const orientConst = horizontal ? HORIZONTAL : VERTICAL;
81
+ const placeConst = placement === 'left' ? LEFT : placement === 'right' ? RIGHT : placement === 'top' ? TOP : BOTTOM;
82
+
83
+ const [curSize, setCurSize] = useState(size);
84
+ const sizeRef = useRef(curSize);
85
+ sizeRef.current = curSize;
86
+
87
+ const [dragging, setDragging] = useState(false);
88
+ const drag = useRef<{
89
+ startSize: number;
90
+ startX: number;
91
+ startY: number;
92
+ } | null>(null);
93
+
94
+ // 容器交叉轴尺寸固定;沿轴尺寸 = max(初始 size, 当前 curSize) + splitterWidth。
95
+ // 用 max 而非单纯 curSize:拖拽时容器不会从「分隔条所在侧」收缩,从而让分隔条能真正跟随鼠标
96
+ // 移动(对齐 Ext.SplitBar 真实行为——分隔条是被拖动的元素,始终跟随指针)。
97
+ const cross = thickness;
98
+ const containerAlong = Math.max(size, curSize) + splitterWidth;
99
+ const containerStyle: CSSProperties = {
100
+ position: 'relative',
101
+ ...(horizontal ? { width: containerAlong, height: cross } : { width: cross, height: containerAlong }),
102
+ };
103
+
104
+ // 「起始侧」= 分隔条贴容器的左/上边缘:RIGHT(分隔条在左)、BOTTOM(分隔条在上);
105
+ // 「结束侧」= 贴右/下边缘:LEFT(分隔条在右)、TOP(分隔条在下)。
106
+ // 起始侧时分隔条沿轴偏移随 curSize 反向计算,使拖拽时分隔条跟随鼠标;结束侧偏移即 curSize。两种 placement 均跟手。
107
+ const isStartSide = placeConst === RIGHT || placeConst === BOTTOM;
108
+ const splitterAlong = isStartSide ? containerAlong - splitterWidth - curSize : curSize;
109
+
110
+ // 分隔条根 class 已是原版 .x-splitbar .x-splitbar-h/v(ext-all.css 仅给 cursor,
111
+ // 无 grip 背景图——这是 Ext 3.2.1 原生表现),故此处不再自绘 grip,纯靠原版类。
112
+ const splitterStyleMerged: CSSProperties = {
113
+ position: 'absolute',
114
+ ...(horizontal
115
+ ? { left: splitterAlong, top: 0, width: splitterWidth, height: cross }
116
+ : { top: splitterAlong, left: 0, height: splitterWidth, width: cross }),
117
+ ...splitterStyle,
118
+ };
119
+
120
+ const resizingStyleMerged: CSSProperties = {
121
+ position: 'absolute',
122
+ ...(horizontal
123
+ ? isStartSide
124
+ ? { left: splitterWidth + splitterAlong, top: 0, width: curSize, height: cross }
125
+ : { left: 0, top: 0, width: curSize, height: cross }
126
+ : isStartSide
127
+ ? { top: splitterWidth + splitterAlong, left: 0, height: curSize, width: cross }
128
+ : { top: 0, left: 0, height: curSize, width: cross }),
129
+ ...resizingStyle,
130
+ };
131
+
132
+ const onSplitterDown = (e: ReactMouseEvent) => {
133
+ e.preventDefault();
134
+ const startSize = sizeRef.current;
135
+ drag.current = { startSize, startX: e.clientX, startY: e.clientY };
136
+ onBeforeResize?.();
137
+ setDragging(true);
138
+ };
139
+
140
+ // 拖拽监听经 useEffect 绑定(React 合成事件内直接 addEventListener 在 Playwright 下不可靠):
141
+ // dragging 变 true 时挂 window mousemove/mouseup,松手或组件卸载时清理。
142
+ useEffect(() => {
143
+ if (!dragging) return;
144
+ const move = (ev: MouseEvent) => {
145
+ const d = drag.current;
146
+ if (!d) return;
147
+ const dx = ev.clientX - d.startX;
148
+ const dy = ev.clientY - d.startY;
149
+ let next: number;
150
+ if (orientConst === HORIZONTAL) {
151
+ // 水平:分隔条在左侧(LEFT)向右拖增大尺寸;在右侧(RIGHT)向右拖减小尺寸
152
+ next = d.startSize + (placeConst === LEFT ? dx : -dx);
153
+ } else {
154
+ // 垂直:分隔条在上侧(TOP)向下拖增大尺寸;在下侧(BOTTOM)向下拖减小尺寸
155
+ next = d.startSize + (placeConst === TOP ? dy : -dy);
156
+ }
157
+ next = clamp(next, minSize, maxSize);
158
+ setCurSize(next);
159
+ };
160
+ const up = () => {
161
+ setDragging(false);
162
+ onMoved?.(sizeRef.current);
163
+ };
164
+ window.addEventListener('mousemove', move);
165
+ window.addEventListener('mouseup', up);
166
+ return () => {
167
+ window.removeEventListener('mousemove', move);
168
+ window.removeEventListener('mouseup', up);
169
+ };
170
+ }, [dragging, orientConst, placeConst, minSize, maxSize, onMoved]);
171
+
172
+ const splitter = (
173
+ <div
174
+ className={`x-splitbar ${horizontal ? 'x-splitbar-h' : 'x-splitbar-v'}`}
175
+ unselectable="on"
176
+ style={splitterStyleMerged}
177
+ onMouseDown={onSplitterDown}
178
+ />
179
+ );
180
+ const resizing = (
181
+ <div style={resizingStyleMerged}>
182
+ {resizingContent}
183
+ </div>
184
+ );
185
+
186
+ const proxyId = id ? `${id}-proxy` : 'splitbar-proxy';
187
+
188
+ return (
189
+ <>
190
+ <div
191
+ id={id}
192
+ data-testid={testId}
193
+ style={containerStyle}
194
+ >
195
+ {resizing}
196
+ {splitter}
197
+ </div>
198
+ {typeof document !== 'undefined' &&
199
+ createPortal(
200
+ <>
201
+ <div
202
+ id={proxyId}
203
+ className={`x-splitbar-proxy ${horizontal ? 'x-splitbar-proxy-h' : 'x-splitbar-proxy-v'}`}
204
+ style={{ display: 'none' }}
205
+ />
206
+ <div
207
+ className="x-drag-overlay"
208
+ style={dragging ? { display: 'block', position: 'fixed', width: '100%', height: '100%', zIndex: 20000 } : { display: 'none' }}
209
+ />
210
+ </>,
211
+ document.body,
212
+ )}
213
+ </>
214
+ );
215
+ }
@@ -0,0 +1,262 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type RefObject,
10
+ } from 'react';
11
+ import { createPortal } from 'react-dom';
12
+
13
+ /**
14
+ * 复刻 Ext.ux.Spotlight(又名 Ext.Spotlight,见 examples/ux/Spotlight.js)。
15
+ *
16
+ * 行为:在 document.body 上创建 4 个绝对定位遮罩(right/left/top/bottom),
17
+ * 围绕目标元素拼成一个「挖洞」的半透明遮罩,使页面除目标元素外全部不可交互。
18
+ * show(el) 时 4 遮罩先从 base 锚点(各面板远端、尺寸 0)收拢态,再动画展开到
19
+ * 围合目标矩形的全尺寸;hide() 反向收拢后移除。等价原始的 createElements /
20
+ * applyBounds(basePts) / applyBounds(展开) / doHide。
21
+ *
22
+ * 视觉完全复用 ext-all.css 既有 .x-spotlight(z-index:8999、position:absolute、
23
+ * background:#ccc),组件仅以内联样式控制 4 面板的 left/top/width/height 与 transition。
24
+ */
25
+
26
+ export type SpotlightTarget =
27
+ | string
28
+ | (() => HTMLElement | null)
29
+ | RefObject<HTMLElement | null>
30
+ | null;
31
+
32
+ export interface SpotlightHandle {
33
+ /** 高亮指定元素(id 选择器 / 函数 / ref)。省略则用最近一次 target。 */
34
+ show: (target?: SpotlightTarget) => void;
35
+ /** 隐藏遮罩。 */
36
+ hide: () => void;
37
+ /** 当前是否高亮中。 */
38
+ readonly active: boolean;
39
+ }
40
+
41
+ export interface SpotlightProps {
42
+ /** 受控是否高亮。true 显示挖洞遮罩,false 隐藏。省略则走命令式 show/hide。 */
43
+ active?: boolean;
44
+ /** 目标元素:id 选择器 / 返回元素函数 / ref。受控模式下用于定位挖洞位置。 */
45
+ target?: SpotlightTarget;
46
+ /** 是否动画展开/收拢(等价原始 animate)。默认 true。 */
47
+ animate?: boolean;
48
+ /** 动画时长(秒),等价原始 duration。默认 0.25。 */
49
+ duration?: number;
50
+ /** CSS easing 名,等价原始 easing(easeNone→linear、easeOut→ease-out 等)。默认 'easeNone'。 */
51
+ easing?: string;
52
+ /** 遮罩 z-index,等价原始 .x-spotlight z-index:8999。默认 8999。 */
53
+ zIndex?: number;
54
+ /** 遮罩背景色,等价原始 .x-spotlight background-color:#ccc。默认 '#ccc'。 */
55
+ color?: string;
56
+ /** 命令式模式下的初始高亮状态。默认 false。 */
57
+ defaultActive?: boolean;
58
+ }
59
+
60
+ interface PanelBox {
61
+ left: number;
62
+ top: number;
63
+ width: number;
64
+ height: number;
65
+ }
66
+ interface Panels {
67
+ left: PanelBox;
68
+ right: PanelBox;
69
+ top: PanelBox;
70
+ bottom: PanelBox;
71
+ }
72
+
73
+ const EASING_MAP: Record<string, string> = {
74
+ easeNone: 'linear',
75
+ easeOut: 'ease-out',
76
+ easeIn: 'ease-in',
77
+ easeInOut: 'ease-in-out',
78
+ };
79
+
80
+ /** 依据目标元素矩形与文档尺寸,计算 4 面板「全展开」与「收拢 base」两套边界。 */
81
+ function computePanels(el: HTMLElement): { full: Panels; base: Panels } {
82
+ const r = el.getBoundingClientRect();
83
+ const sx = window.scrollX || window.pageXOffset || 0;
84
+ const sy = window.scrollY || window.pageYOffset || 0;
85
+ const L = r.left + sx;
86
+ const T = r.top + sy;
87
+ const R = r.right + sx;
88
+ const B = r.bottom + sy;
89
+ const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth + sx);
90
+ const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight + sy);
91
+
92
+ return {
93
+ full: {
94
+ left: { left: 0, top: 0, width: L, height: B },
95
+ right: { left: R, top: T, width: docW - R, height: docH - T },
96
+ top: { left: L, top: 0, width: docW - L, height: T },
97
+ bottom: { left: 0, top: B, width: R, height: docH - B },
98
+ },
99
+ // base:各面板收拢到其「远端」锚点(尺寸 0),动画时从外部向内合拢
100
+ base: {
101
+ left: { left: 0, top: 0, width: L, height: 0 },
102
+ right: { left: R, top: docH, width: 0, height: 0 },
103
+ top: { left: docW, top: 0, width: 0, height: T },
104
+ bottom: { left: 0, top: B, width: 0, height: docH - B },
105
+ },
106
+ };
107
+ }
108
+
109
+ export const Spotlight = forwardRef<SpotlightHandle, SpotlightProps>(
110
+ (
111
+ {
112
+ active,
113
+ target,
114
+ animate = true,
115
+ duration = 0.25,
116
+ easing = 'easeNone',
117
+ zIndex,
118
+ color = '#ccc',
119
+ },
120
+ ref,
121
+ ) => {
122
+ const isControlled = active !== undefined;
123
+ const [internalActive, setInternalActive] = useState(false);
124
+ const shown = isControlled ? (active as boolean) : internalActive;
125
+
126
+ // 命令式 target 覆盖受控 target(受控每渲染更新,命令式经 state 更新,二者统一到 targetRef)
127
+ const [impTarget, setImpTarget] = useState<SpotlightTarget | undefined>(undefined);
128
+ const targetRef = useRef<SpotlightTarget | null>(target ?? null);
129
+ targetRef.current = impTarget !== undefined ? impTarget : target ?? null;
130
+
131
+ const resolveTarget = useCallback((): HTMLElement | null => {
132
+ const t = targetRef.current;
133
+ if (!t) return null;
134
+ if (typeof t === 'string') return document.querySelector<HTMLElement>(t);
135
+ if (typeof t === 'function') return t();
136
+ return t.current;
137
+ }, []);
138
+
139
+ const [mounted, setMounted] = useState(false); // 4 面板是否在 DOM
140
+ const [expanded, setExpanded] = useState(false); // 是否处于全展开边界
141
+ const [animating, setAnimating] = useState(false); // 仅在 show/hide 过渡窗口内为 true
142
+ const [panels, setPanels] = useState<{ full: Panels; base: Panels } | null>(null);
143
+ const rafRef = useRef<number>();
144
+ const timerRef = useRef<number>();
145
+
146
+ const recompute = useCallback(() => {
147
+ const el = resolveTarget();
148
+ if (!el) return;
149
+ setPanels(computePanels(el));
150
+ }, [resolveTarget]);
151
+
152
+ // 当前解析出的目标 DOM 元素(稳定引用)。作为 effect 依赖,目标切换时触发边界重算,
153
+ // 用元素引用而非函数可避免内联 target 每次渲染变化引发的无限循环。
154
+ const currentTargetEl = resolveTarget();
155
+
156
+ useEffect(() => {
157
+ window.clearTimeout(timerRef.current);
158
+ if (!shown) {
159
+ setExpanded(false); // 收拢
160
+ if (animate) setAnimating(true);
161
+ const delay = animate ? duration * 1000 : 0;
162
+ timerRef.current = window.setTimeout(() => {
163
+ setMounted(false);
164
+ setPanels(null);
165
+ setAnimating(false);
166
+ }, delay);
167
+ window.removeEventListener('resize', recompute);
168
+ window.removeEventListener('scroll', recompute, true);
169
+ return;
170
+ }
171
+ // shown === true
172
+ recompute();
173
+ if (!mounted) {
174
+ // 首次显示:挂载 + 先收拢再展开(等价原始 applyBounds 先 base 后展开的时序)
175
+ setMounted(true);
176
+ setExpanded(false);
177
+ if (animate) setAnimating(true);
178
+ rafRef.current = window.requestAnimationFrame(() => {
179
+ rafRef.current = window.requestAnimationFrame(() => setExpanded(true));
180
+ });
181
+ window.addEventListener('resize', recompute);
182
+ window.addEventListener('scroll', recompute, true);
183
+ if (animate) {
184
+ timerRef.current = window.setTimeout(() => setAnimating(false), duration * 1000);
185
+ }
186
+ } else {
187
+ // 已显示时切换目标(如引导式 show(panel2)):仅更新边界,保留展开态,
188
+ // 4 面板靠 CSS transition 平滑滑动到新目标(等价原始 applyBounds 在 active 时重算)。
189
+ setExpanded(true);
190
+ if (animate) {
191
+ setAnimating(true);
192
+ window.clearTimeout(timerRef.current);
193
+ timerRef.current = window.setTimeout(() => setAnimating(false), duration * 1000);
194
+ }
195
+ }
196
+ return () => {
197
+ window.removeEventListener('resize', recompute);
198
+ window.removeEventListener('scroll', recompute, true);
199
+ };
200
+ }, [shown, animate, duration, impTarget, currentTargetEl, recompute]);
201
+
202
+ useEffect(
203
+ () => () => {
204
+ window.cancelAnimationFrame(rafRef.current as number);
205
+ window.clearTimeout(timerRef.current);
206
+ },
207
+ [],
208
+ );
209
+
210
+ useImperativeHandle(
211
+ ref,
212
+ () => ({
213
+ show: (t?: SpotlightTarget) => {
214
+ // 注意:t 可能是函数(如 () => el),必须用 updater 包裹,否则 React 会把 t 当成
215
+ // setState 的更新器函数执行掉(impTarget 变成 t(prev) 的返回值而非 t 本身)。
216
+ if (t !== undefined) setImpTarget(() => t);
217
+ setInternalActive(true);
218
+ },
219
+ hide: () => setInternalActive(false),
220
+ get active() {
221
+ return shown;
222
+ },
223
+ }),
224
+ [shown],
225
+ );
226
+
227
+ if (!mounted || !panels) return null;
228
+
229
+ const cssEase = EASING_MAP[easing] ?? 'linear';
230
+ const transition =
231
+ animate && animating ? `left ${duration}s ${cssEase}, top ${duration}s ${cssEase}, width ${duration}s ${cssEase}, height ${duration}s ${cssEase}` : 'none';
232
+
233
+ // 四个面板:展开取 full,收拢取 base
234
+ const renderPanel = (key: keyof Panels) => {
235
+ const box = expanded ? panels.full[key] : panels.base[key];
236
+ const style: CSSProperties = {
237
+ position: 'absolute',
238
+ left: box.left,
239
+ top: box.top,
240
+ width: box.width,
241
+ height: box.height,
242
+ zIndex,
243
+ backgroundColor: color,
244
+ transition,
245
+ pointerEvents: 'auto',
246
+ };
247
+ return <div key={key} className="x-spotlight" style={style} data-spotlight-panel={key} />;
248
+ };
249
+
250
+ return createPortal(
251
+ <>
252
+ {renderPanel('left')}
253
+ {renderPanel('right')}
254
+ {renderPanel('top')}
255
+ {renderPanel('bottom')}
256
+ </>,
257
+ document.body,
258
+ );
259
+ },
260
+ );
261
+
262
+ Spotlight.displayName = 'Spotlight';
@@ -0,0 +1,118 @@
1
+ import React, { useImperativeHandle, useRef, forwardRef } from 'react';
2
+ import Toolbar, { type ToolbarItem } from './Toolbar';
3
+
4
+ /**
5
+ * StatusBar —— 保真复刻 Ext.ux.StatusBar(examples/ux/statusbar/StatusBar.js)。
6
+ *
7
+ * 保真契约(DOM/class 严格对齐源码 golden 实测):
8
+ * - 本质为 Toolbar 子类:根 class = "x-toolbar x-small-editor x-statusbar x-toolbar-layout-ct"
9
+ * (仅比普通 Toolbar 多 x-statusbar;x-toolbar-layout-ct 由 ToolbarLayout.onLayout 注入)。
10
+ * - 状态元素:afterRender 注入一个 Toolbar.TextItem,cls = "xtb-text x-status-text {iconCls}",
11
+ * 文本取 text || defaultText。该元素位于 '->' 分隔之前(statusAlign:'left')或之后('right')。
12
+ * - 其余 items 经 '->' 推到对侧(左对齐 → 状态在左、工具项在右;右对齐反之)。
13
+ * - 状态文本在此布局下为内容宽度(非贪婪占满),与 golden 实测一致。
14
+ *
15
+ * 样式:复用 StatusBar.css(examples/ux/statusbar/css/StatusBar.css,已复制并 @import)。
16
+ * x-status-valid/x-status-busy/x-status-error 等 statusCls 自带背景图标(accept.png 等),零 CSS 契约破坏。
17
+ *
18
+ * 交互(行为契约):setStatus 更新文本/图标;showBusy 切 x-status-busy + busyText;clearStatus 复位。
19
+ */
20
+ export interface StatusBarProps {
21
+ items?: ToolbarItem[];
22
+ /** 初始状态文本(取 text || defaultText || emptyText) */
23
+ text?: string;
24
+ /** 状态图标 class,如 'x-status-valid' / 'x-status-busy' / 'x-status-error'(需 StatusBar.css 已定义背景图) */
25
+ iconCls?: string;
26
+ defaultText?: string;
27
+ /** 状态元素对齐:'left'(默认,状态在左、items 在右)或 'right'(状态在右、items 在左) */
28
+ statusAlign?: 'left' | 'right';
29
+ width?: number | string;
30
+ style?: React.CSSProperties;
31
+ className?: string;
32
+ /** 数据测试 id,挂在根 div 上(供保真截图/行为测试定位) */
33
+ testId?: string;
34
+ }
35
+
36
+ export interface StatusBarHandle {
37
+ /** 设置状态文本/图标;o 可为字符串(视为文本)或 {text, iconCls, clear} */
38
+ setStatus: (o: string | { text?: string; iconCls?: string }) => void;
39
+ /** 显示忙碌态:文本 busyText + 图标 busyIconCls(默认 x-status-busy / 'Loading...') */
40
+ showBusy: (o?: { text?: string; iconCls?: string }) => void;
41
+ /** 清除状态:复位为 defaultText / 移除图标 class */
42
+ clearStatus: () => void;
43
+ }
44
+
45
+ const emptyText = ' ';
46
+
47
+ export const StatusBar = forwardRef<StatusBarHandle, StatusBarProps>(function StatusBar(
48
+ {
49
+ items = [],
50
+ text,
51
+ iconCls,
52
+ defaultText = '',
53
+ statusAlign = 'left',
54
+ width,
55
+ style,
56
+ className,
57
+ testId,
58
+ },
59
+ ref,
60
+ ) {
61
+ const [statusText, setStatusText] = React.useState<string>(text || defaultText || emptyText);
62
+ const [statusCls, setStatusCls] = React.useState<string>(iconCls || '');
63
+
64
+ const currIconRef = useRef<string>(iconCls || '');
65
+
66
+ useImperativeHandle(ref, () => ({
67
+ setStatus(o) {
68
+ if (typeof o === 'string') {
69
+ setStatusText(o);
70
+ return;
71
+ }
72
+ if (o.text !== undefined) setStatusText(o.text);
73
+ if (o.iconCls !== undefined) {
74
+ currIconRef.current = o.iconCls;
75
+ setStatusCls(o.iconCls);
76
+ }
77
+ },
78
+ showBusy(o = {}) {
79
+ const busyIcon = o.iconCls ?? 'x-status-busy';
80
+ const busyText = o.text ?? 'Loading...';
81
+ currIconRef.current = busyIcon;
82
+ setStatusCls(busyIcon);
83
+ setStatusText(busyText);
84
+ },
85
+ clearStatus() {
86
+ currIconRef.current = '';
87
+ setStatusCls('');
88
+ setStatusText(defaultText || emptyText);
89
+ },
90
+ }), [defaultText]);
91
+
92
+ const statusItem: ToolbarItem = {
93
+ xtype: 'tbtext',
94
+ text: statusText,
95
+ cls: `x-status-text ${statusCls}`.trim(),
96
+ } as ToolbarItem;
97
+
98
+ const allItems: ToolbarItem[] =
99
+ statusAlign === 'right'
100
+ ? ['->', statusItem, ...items]
101
+ : [statusItem, '->', ...items];
102
+
103
+ const rootCls = [
104
+ 'x-toolbar',
105
+ 'x-small-editor',
106
+ 'x-statusbar',
107
+ 'x-toolbar-layout-ct',
108
+ className,
109
+ ]
110
+ .filter(Boolean)
111
+ .join(' ');
112
+
113
+ return (
114
+ <Toolbar items={allItems} width={width} style={style} testId={testId} rootClass={rootCls} />
115
+ );
116
+ });
117
+
118
+ export default StatusBar;