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,122 @@
1
+ import React, { useState } from 'react';
2
+
3
+ export interface CardItem {
4
+ /** 卡片标题(渲染为卡片自身 .x-panel-header)。可空表示纯内容卡片。 */
5
+ title?: string;
6
+ /** 直接 HTML 内容(对齐 Ext Panel.html 直渲)。 */
7
+ html?: string;
8
+ children?: React.ReactNode;
9
+ /** 卡片面板是否带边框。默认 true(对齐 Ext 默认 Panel.border:true,会与根面板形成双层边框);设 false 可消除内层框,呈现官方 card 示例 defaults:{border:false} 的铺满单层观感。 */
10
+ border?: boolean;
11
+ }
12
+
13
+ export interface CardLayoutProps {
14
+ /** 根 Panel 总宽(等同 Ext width)。默认 300。 */
15
+ width?: number | string;
16
+ /** 根 Panel 总高。默认 200。 */
17
+ height?: number | string;
18
+ /** 卡片集合,同一时刻仅 activeIndex 指向的卡片可见。 */
19
+ items: CardItem[];
20
+ /** 受控激活索引。 */
21
+ activeIndex?: number;
22
+ /** 非受控默认激活索引。默认 0。 */
23
+ defaultActiveIndex?: number;
24
+ /** 根 body 内边距(对齐 Ext bodyStyle padding)。默认 8。 */
25
+ bodyPadding?: number | string;
26
+ /** 根面板是否带边框。默认 true(对齐 Ext 默认 Panel.border:true,会与卡片框形成双层边框);设 false 可消除外层框,呈现官方 card 示例"卡片自身为唯一边框"的铺满单层观感。 */
27
+ border?: boolean;
28
+ testId?: string;
29
+ className?: string;
30
+ }
31
+
32
+ // 实测 golden 常量(content-box 模型,与 Ext 一致):
33
+ // - 根 .x-panel:border 0,offset = width/height
34
+ // - 根 body(x-panel-body-noheader):border 1px 每边 + padding 8 每边
35
+ // → body 内容高 = height - 2 - 2*pad;active 卡片 height:100% 即撑满此内容区
36
+ // - 卡片采用 flex 列布局:header 取自然高度,body flex:1 精确填满剩余空间,
37
+ // 不再依赖硬编码 header 高度,避免 header 实际高度 ≠ 常量时内容溢出、根 body
38
+ // overflow:hidden 裁掉卡片下边框(即"下边框不见了"的原判因)。
39
+ const BODY_BORDER = 1;
40
+ const DEFAULT_PAD = 8;
41
+
42
+ /**
43
+ * 保真映射(来源 Ext.layout.CardLayout + Ext.Panel):
44
+ * - 根 Panel 无 header,body 为 .x-panel-body.x-panel-body-noheader(bodyStyle padding 落在此)。
45
+ * - 每张卡片是 body 直接子节点 .x-panel(带自身 header/body,无 noborder,故 header 26px)。
46
+ * - activeItem 可见(display:block),其余加 .x-hide-display(display:none!important)。
47
+ * - CardLayout.setItemSize 把 active 卡片设为 body 内容尺寸,故 active 卡片 width/height:100% 撑满。
48
+ * 渲染注意:纯 CSS 下父固定高度不会自动撑开 bwrap/body,故显式 content-box 高度对齐 golden。
49
+ */
50
+ export const CardLayout: React.FC<CardLayoutProps> = ({
51
+ width = 300,
52
+ height = 200,
53
+ items,
54
+ activeIndex,
55
+ defaultActiveIndex = 0,
56
+ bodyPadding = DEFAULT_PAD,
57
+ border = true,
58
+ testId,
59
+ className,
60
+ }) => {
61
+ const [internal, setInternal] = useState(defaultActiveIndex);
62
+ const active = activeIndex ?? internal;
63
+ const setActive = (i: number) => {
64
+ if (activeIndex === undefined) setInternal(i);
65
+ };
66
+
67
+ const padNum = typeof bodyPadding === 'number' ? bodyPadding : DEFAULT_PAD;
68
+ const rootH = typeof height === 'number' ? height : 200;
69
+ const bodyContentH = rootH - (border ? BODY_BORDER * 2 : 0) - padNum * 2;
70
+ const rootNoBorder = border === false;
71
+
72
+ return (
73
+ <div
74
+ className={['x-panel', rootNoBorder ? 'x-panel-noborder' : '', className].filter(Boolean).join(' ')}
75
+ data-testid={testId}
76
+ style={{ width, height }}
77
+ >
78
+ <div className="x-panel-bwrap" style={{ height: '100%' }}>
79
+ <div
80
+ className={['x-panel-body', 'x-panel-body-noheader', rootNoBorder ? 'x-panel-body-noborder' : ''].filter(Boolean).join(' ')}
81
+ style={{ padding: bodyPadding, height: bodyContentH }}
82
+ >
83
+ {items.map((it, i) => {
84
+ const isActive = i === active;
85
+ const noBorder = it.border === false;
86
+ return (
87
+ <div
88
+ key={i}
89
+ className={['x-panel', isActive ? '' : 'x-hide-display', noBorder ? 'x-panel-noborder' : ''].filter(Boolean).join(' ')}
90
+ style={{ width: '100%', height: '100%' }}
91
+ >
92
+ <div className="x-panel-bwrap" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
93
+ {it.title !== undefined && (
94
+ <div
95
+ className={['x-panel-header', noBorder ? 'x-panel-header-noborder' : ''].filter(Boolean).join(' ')}
96
+ unselectable="on"
97
+ onClick={() => setActive(i)}
98
+ >
99
+ <span className="x-panel-header-text">{it.title}</span>
100
+ </div>
101
+ )}
102
+ <div
103
+ className={['x-panel-body', it.title === undefined ? 'x-panel-body-noheader' : '', noBorder ? 'x-panel-body-noborder' : ''].filter(Boolean).join(' ')}
104
+ style={{ boxSizing: 'border-box', flex: 1, minHeight: 0, overflow: 'auto' }}
105
+ >
106
+ {it.html !== undefined ? (
107
+ <span dangerouslySetInnerHTML={{ __html: it.html }} />
108
+ ) : (
109
+ it.children
110
+ )}
111
+ </div>
112
+ </div>
113
+ </div>
114
+ );
115
+ })}
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ };
121
+
122
+ export default CardLayout;
@@ -0,0 +1,95 @@
1
+ import React from 'react';
2
+
3
+ export interface CenterItem {
4
+ /** 面板标题(渲染 x-panel-header)。省略则无 header、整高即 body。 */
5
+ title?: string;
6
+ /** 主体内容(纯文本/HTML 字符串);也可通过 children 渲染任意 React 节点。 */
7
+ html?: string;
8
+ children?: React.ReactNode;
9
+ /** 透传到 .x-panel-body 的 inline style */
10
+ bodyStyle?: React.CSSProperties;
11
+ /**
12
+ * 子面板固定宽(px 或百分比字符串,如 300 / '75%')。
13
+ * 指定后子面板宽度锁定为该值,并借 `.ux-layout-center-item{margin:0 auto}` 在其容器内水平居中;
14
+ * 省略则子面板铺满容器宽(此时 margin 自动失效,无居中效果)——对齐 Ext item.width 逻辑。
15
+ */
16
+ width?: number | string;
17
+ /**
18
+ * 允许子面板按内容自定高(不强制铺满容器高)。对齐 Ext `autoHeight:true`。
19
+ * 默认 false:子面板高度 = 容器内容区高(FitLayout 行为)。
20
+ */
21
+ autoHeight?: boolean;
22
+ }
23
+
24
+ export interface CenterLayoutProps {
25
+ /** 容器总宽(px),对齐 Ext.Container width,也是 fit 子面板目标宽。 */
26
+ width: number;
27
+ /** 容器总高(px),对齐 Ext.Container height,也是 fit 子面板目标高。 */
28
+ height: number;
29
+ /** 唯一子面板配置(CenterLayout 仅容纳一个子项,继承自 FitLayout)。 */
30
+ item: CenterItem;
31
+ /** 数据测试 id,挂在根容器上。 */
32
+ testId?: string;
33
+ style?: React.CSSProperties;
34
+ className?: string;
35
+ }
36
+
37
+ // Ext 3.2.1 实测:x-panel 本身 border 为 0,header 高 26px,body 占剩余(含 1px 底边框,border-box)。
38
+ const HEADER_H = 26;
39
+
40
+ /**
41
+ * 复刻 Ext.ux.layout.CenterLayout(Ext 3.2.1,UX 扩展,继承 FitLayout)。
42
+ *
43
+ * DOM 对齐 CenterLayout.setItemSize 实测真值:
44
+ * - 容器根加 `ux-layout-center` 类(其 CSS 令 `.ux-layout-center .x-panel-body{text-align:center}`);
45
+ * - 唯一的子面板加 `ux-layout-center-item` 类(其 CSS `.ux-layout-center-item{margin:0 auto;text-align:left}`
46
+ * 令带固定宽的块级子面板在容器内水平居中);
47
+ * - setItemSize:若 item.width 有值,则 size.width = item.width,再 item.setSize(size),
48
+ * 故子面板宽 = item.width(居中),高 = 容器内容区高(Fit 行为),autoHeight:true 时高交还内容自适应。
49
+ *
50
+ * 说明:CenterLayout 仅做**水平**居中(Ext 原版即如此,高度方向按 Fit 铺满/自适),并非垂直居中。
51
+ * 专属两条 UX CSS 已并入 src/styles/ext-theme.css(源自 examples/ux/css/CenterLayout.css)。
52
+ */
53
+ export function CenterLayout({
54
+ width,
55
+ height,
56
+ item,
57
+ testId,
58
+ style,
59
+ className = '',
60
+ }: CenterLayoutProps) {
61
+ const rootStyle: React.CSSProperties = { width, height, ...style };
62
+ const headerH = item.title ? HEADER_H : 0;
63
+ const bodyH = height - headerH;
64
+ const itemW = item.width != null ? item.width : width;
65
+ const itemH = item.autoHeight ? undefined : bodyH;
66
+
67
+ return (
68
+ <div
69
+ data-testid={testId}
70
+ className={['center-layout-root', 'ux-layout-center', className].filter(Boolean).join(' ')}
71
+ style={rootStyle}
72
+ >
73
+ <div
74
+ className={['x-panel', 'ux-layout-center-item'].join(' ')}
75
+ style={{ width: itemW, height: itemH }}
76
+ >
77
+ {item.title && (
78
+ <div className="x-panel-header x-unselectable" unselectable="on">
79
+ <span className="x-panel-header-text">{item.title}</span>
80
+ </div>
81
+ )}
82
+ <div className="x-panel-bwrap">
83
+ <div
84
+ className={['x-panel-body', !item.title && 'x-panel-body-noheader'].filter(Boolean).join(' ')}
85
+ style={{ height: item.autoHeight ? undefined : bodyH, ...item.bodyStyle }}
86
+ >
87
+ {item.html != null ? item.html : item.children}
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ export default CenterLayout;
@@ -0,0 +1,200 @@
1
+ import { forwardRef, useImperativeHandle, useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
2
+
3
+ /**
4
+ * Ext.form.CheckboxGroup 保真复刻(来源 src/widgets/form/CheckboxGroup.js)
5
+ *
6
+ * 布局契约(与 RadioGroup 完全同构,保证两类选择组视觉一致):
7
+ * - 外层 x-form-item 提供与所有表单字段一致的标签列与左偏移(fieldLabel/labelWidth/labelAlign)
8
+ * - 内容区 div.x-form-element(padding-left = labelWidth + labelPad)
9
+ * - 内层 div.x-form-check-group 用 flex 排布(gap:8,垂直居中):
10
+ * > columns='auto' 或 1 → 横向单行(flex nowrap)
11
+ * > columns>1 → 横向多列网格(flex wrap,每列 width = calc(100/cols% - 8px))
12
+ * - 每项直接渲染为 span.x-form-check-wrap(input.x-form-checkbox + label.x-form-cb-label),
13
+ * 不再套 x-form-item.x-hide-label 空标签列(旧实现导致每项缩进 105px、间距与 Radio 不一致)
14
+ *
15
+ * 行为契约(对齐 Ext CheckboxGroup):
16
+ * - value/defaultValue 为已勾选的 inputValue 数组(初始态)
17
+ * - 组内的 checkbox 互不排斥(各自独立勾选,区别于 RadioGroup)
18
+ */
19
+ export interface CheckboxGroupItem {
20
+ boxLabel?: ReactNode;
21
+ inputValue?: string;
22
+ name?: string;
23
+ checked?: boolean;
24
+ defaultChecked?: boolean;
25
+ disabled?: boolean;
26
+ onChange?: (checked: boolean) => void;
27
+ }
28
+
29
+ export interface CheckboxGroupProps {
30
+ /** 列数:数字(如 2)或 'auto'(每项一列)。默认 'auto'(对齐 Ext 横向单行)。 */
31
+ columns?: number | 'auto';
32
+ /** 子项配置数组。 */
33
+ items: CheckboxGroupItem[];
34
+ /** 已勾选的 inputValue 数组(初始态,受控可选)。 */
35
+ value?: string[];
36
+ /** 默认勾选的 inputValue 数组。 */
37
+ defaultValue?: string[];
38
+ /** 标签(对齐 Ext fieldLabel);提供后作为「自带 label 的整字段」直接放入 FormPanel,无需再包 FormField。 */
39
+ fieldLabel?: ReactNode;
40
+ /** 标签对齐方式,默认 'left'(对齐 RadioGroup / FormLayout)。 */
41
+ labelAlign?: 'left' | 'top' | 'right';
42
+ /** 标签宽度,默认 100(对齐 RadioGroup / FormLayout)。 */
43
+ labelWidth?: number;
44
+ /** 标签与内容区间距,默认 5。 */
45
+ labelPad?: number;
46
+ /** 标签后缀,默认 ':'。 */
47
+ labelSeparator?: string;
48
+ /** 隐藏标签(仍保留标签列宽,对齐 Ext hideLabel)。 */
49
+ hideLabel?: boolean;
50
+ /** 组内内容区宽度(对齐 Ext field width);不传则按内容自适应,与其它表单字段对齐时显式传入。 */
51
+ width?: number;
52
+ className?: string;
53
+ testId?: string;
54
+ style?: CSSProperties;
55
+ }
56
+
57
+ /**
58
+ * 运行时句柄(对齐 Ext.form.CheckboxGroup 动态增删改选项):
59
+ * - addItem(item, index?):在末尾(或指定位置)插入一个 checkbox 项
60
+ * - removeItem(index):删除指定位置的项
61
+ * - updateItem(index, partial):就地更新某项的 boxLabel/checked/disabled 等
62
+ * - getItems():读当前项数组
63
+ */
64
+ export interface CheckboxGroupHandle {
65
+ addItem: (item: CheckboxGroupItem, index?: number) => void;
66
+ removeItem: (index: number) => void;
67
+ updateItem: (index: number, partial: Partial<CheckboxGroupItem>) => void;
68
+ getItems: () => CheckboxGroupItem[];
69
+ }
70
+
71
+ export const CheckboxGroup = forwardRef<CheckboxGroupHandle, CheckboxGroupProps>(
72
+ ({
73
+ columns = 'auto',
74
+ items,
75
+ value,
76
+ defaultValue,
77
+ fieldLabel,
78
+ labelAlign = 'left',
79
+ labelWidth = 100,
80
+ labelPad = 5,
81
+ labelSeparator = ':',
82
+ hideLabel,
83
+ width = 300,
84
+ className,
85
+ testId,
86
+ style,
87
+ }: CheckboxGroupProps, ref) => {
88
+ // 单源数组法:items 为唯一真相,句柄直接基于 prev 纯函数改写
89
+ const [dynItems, setDynItems] = useState<CheckboxGroupItem[]>(() => items);
90
+ const itemsRef = useRef(dynItems);
91
+ itemsRef.current = dynItems;
92
+
93
+ useImperativeHandle(
94
+ ref,
95
+ () => ({
96
+ addItem: (item: CheckboxGroupItem, index?: number) =>
97
+ setDynItems((prev) => {
98
+ const next = [...prev];
99
+ if (index === undefined || index >= next.length) next.push(item);
100
+ else next.splice(index, 0, item);
101
+ return next;
102
+ }),
103
+ removeItem: (index: number) => setDynItems((prev) => prev.filter((_, i) => i !== index)),
104
+ updateItem: (index: number, partial: Partial<CheckboxGroupItem>) =>
105
+ setDynItems((prev) => prev.map((it, i) => (i === index ? { ...it, ...partial } : it))),
106
+ getItems: () => itemsRef.current,
107
+ }),
108
+ [],
109
+ );
110
+
111
+ useEffect(() => setDynItems(items), [items]);
112
+
113
+ // 布局对齐 RadioGroup:默认横向单行(columns='auto'/1),columns>1 时横向多列网格。
114
+ const cols = columns === 'auto' ? 1 : Number(columns) > 1 ? Number(columns) : 1;
115
+ const multi = cols > 1;
116
+
117
+ const initialChecked = (it: CheckboxGroupItem) => {
118
+ if (it.checked !== undefined) return it.checked;
119
+ if (value && it.inputValue !== undefined) return value.includes(it.inputValue);
120
+ if (defaultValue && it.inputValue !== undefined) return defaultValue.includes(it.inputValue);
121
+ return it.defaultChecked ?? false;
122
+ };
123
+
124
+ // 对齐 RadioGroup / FormLayout:自带 label 的整字段,直接放入 FormPanel。
125
+ // 外层 x-form-item 提供与所有表单字段一致的标签列与左偏移,width 作用于内容区。
126
+ const isTop = labelAlign === 'top';
127
+ const labelStyle: CSSProperties = isTop ? { width: 'auto' } : { width: `${labelWidth}px` };
128
+ const itemClass = ['x-form-item', `x-form-label-${labelAlign}`].filter(Boolean).join(' ');
129
+
130
+ // 内层容器:与 RadioGroup 完全同构的 flex 布局(gap 8 / 垂直居中)。
131
+ const groupStyle: CSSProperties = {
132
+ display: 'flex',
133
+ flexWrap: multi ? 'wrap' : 'nowrap',
134
+ gap: 8,
135
+ alignItems: 'center',
136
+ ...(width != null ? { width } : {}),
137
+ ...style,
138
+ };
139
+
140
+ const rows = dynItems.map((it, i) => <CheckboxRow key={i} item={it} checked={initialChecked(it)} />);
141
+
142
+ return (
143
+ <div className={[itemClass, className].filter(Boolean).join(' ')} tabIndex={-1}>
144
+ {!hideLabel && fieldLabel != null && (
145
+ <label style={labelStyle} className="x-form-item-label">
146
+ {fieldLabel}
147
+ {labelSeparator}
148
+ </label>
149
+ )}
150
+ <div
151
+ className="x-form-element"
152
+ style={isTop ? { paddingLeft: 0 } : { paddingLeft: `${labelWidth + labelPad}px` }}
153
+ >
154
+ <div className="x-form-check-group" data-testid={testId} style={groupStyle}>
155
+ {multi
156
+ ? rows.map((r, i) => (
157
+ <div key={i} style={{ width: `calc(${100 / cols}% - 8px)` }}>
158
+ {r}
159
+ </div>
160
+ ))
161
+ : rows}
162
+ </div>
163
+ </div>
164
+ <div className="x-form-clear-left" />
165
+ </div>
166
+ );
167
+ });
168
+
169
+ CheckboxGroup.displayName = 'CheckboxGroup';
170
+
171
+ /** 单项:与 Radio 完全同构的 span.x-form-check-wrap(input + label),由外层 flex 容器统一排布。 */
172
+ function CheckboxRow({ item, checked }: { item: CheckboxGroupItem; checked: boolean }) {
173
+ const [internal, setInternal] = useState(checked);
174
+ const isChecked = item.checked !== undefined ? item.checked : internal;
175
+ const id = item.name ? `${item.name}-${item.inputValue ?? ''}` : undefined;
176
+
177
+ return (
178
+ <span className={['x-form-check-wrap', item.disabled ? 'x-item-disabled' : ''].filter(Boolean).join(' ')}>
179
+ <input
180
+ id={id}
181
+ name={item.name}
182
+ type="checkbox"
183
+ className="x-form-field x-form-checkbox"
184
+ value={item.inputValue}
185
+ checked={isChecked}
186
+ disabled={item.disabled}
187
+ onChange={(e) => {
188
+ const v = e.target.checked;
189
+ if (item.checked === undefined) setInternal(v);
190
+ item.onChange?.(v);
191
+ }}
192
+ />
193
+ {item.boxLabel != null && (
194
+ <label htmlFor={id} className="x-form-cb-label">
195
+ {item.boxLabel}
196
+ </label>
197
+ )}
198
+ </span>
199
+ );
200
+ }
@@ -0,0 +1,195 @@
1
+ import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { ColorPalette, COLORS } from './ColorPalette';
4
+
5
+ export interface ColorMenuProps {
6
+ /** 是否展开。受控,由触发器(如按钮)管理。 */
7
+ open?: boolean;
8
+ /** 触发元素(用于定位浮层到其下方,对齐 Ext 菜单定位)。 */
9
+ anchorRef?: React.RefObject<HTMLElement>;
10
+ /** 初始选中色(6 位 hex,可带 #)。 */
11
+ value?: string;
12
+ /** 自定义色板(覆盖默认 40 色),对齐 Ext.ColorPalette.colors。 */
13
+ colors?: string[];
14
+ /** 选中回调,参数为大写 6 位 hex(不带 #)。 */
15
+ onSelect?: (color: string) => void;
16
+ /** 关闭回调(外部点击 / Esc / 选中后)。 */
17
+ onClose?: () => void;
18
+ }
19
+
20
+ const norm = (c?: string): string | null => {
21
+ if (!c) return null;
22
+ const s = c.trim();
23
+ if (!/^#?[0-9a-fA-F]{6}$/.test(s)) return null;
24
+ return s.replace('#', '').toUpperCase();
25
+ };
26
+
27
+ export interface ColorMenuHandle {
28
+ /** 替换整个色板 */
29
+ setColors: (colors: string[]) => void;
30
+ /** 追加一个色(index 省略则末尾) */
31
+ addColor: (hex: string, index?: number) => void;
32
+ /** 删除指定色(大小写/# 无关) */
33
+ removeColor: (hex: string) => void;
34
+ /** 读取当前色板 */
35
+ getColors: () => string[];
36
+ /** 运行时选中某色(大写 6 位 hex) */
37
+ select: (color: string) => void;
38
+ /** 读取当前选中色 */
39
+ getSelected: () => string | null;
40
+ /** 打开浮层 */
41
+ open: () => void;
42
+ /** 关闭浮层 */
43
+ close: () => void;
44
+ /** 是否展开 */
45
+ isOpen: () => boolean;
46
+ }
47
+
48
+ /**
49
+ * ColorMenu —— 保真复刻 Ext.menu.ColorMenu(src/widgets/menu/ColorMenu.js)。
50
+ *
51
+ * 保真契约(DOM/class 严格对齐 golden 实测):
52
+ * - 根:x-menu x-menu-floating x-layer x-color-menu x-menu-plain x-menu-nosep
53
+ * (plain:true → x-menu-plain;showSeparator:false → x-menu-nosep;cls:'x-color-menu')
54
+ * - a.x-menu-focus(隐藏聚焦元素)+ ul.x-menu-list(padding 2px)
55
+ * - li.x-menu-list-item(padding 上下 1px)→ 直接子 div.x-color-palette(无 a.x-menu-item 包裹,
56
+ * 与 Ext Menu 的组件型 item 直接 render 进 li 一致)
57
+ * - 调色板复用 ColorPalette(40 色,顺序与 Ext 一致),选中后 hideOnClick→onClose。
58
+ * - 浮层用 Portal 渲染到 body(对齐 Ext 菜单浮动到 body),fixed 定位到触发器下方,
59
+ * 整数坐标(对齐 Ext.Layer)。
60
+ *
61
+ * 全部样式来自 ext-all.css,零新增 CSS 契约破坏。
62
+ */
63
+ export const ColorMenu = forwardRef<ColorMenuHandle, ColorMenuProps>(function ColorMenu(
64
+ { open, anchorRef, value, colors, onSelect, onClose },
65
+ ref,
66
+ ) {
67
+ // 双轨:prop 初始化 + 句柄运行时改写 + effect 同步
68
+ const [dynOpen, setDynOpen] = useState<boolean>(open ?? false);
69
+ const [dynValue, setDynValue] = useState<string | undefined>(value);
70
+ const [dynColors, setDynColors] = useState<string[]>(colors ?? COLORS);
71
+ const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
72
+
73
+ const openRef = useRef(dynOpen);
74
+ const selRef = useRef<string | null>(norm(dynValue));
75
+ const colorsRef = useRef<string[]>(dynColors);
76
+
77
+ useImperativeHandle(
78
+ ref,
79
+ () => ({
80
+ setColors: (c) => {
81
+ colorsRef.current = c;
82
+ setDynColors(c);
83
+ },
84
+ addColor: (hex, index) => {
85
+ const up = norm(hex) ?? hex.toUpperCase().replace('#', '');
86
+ const cur = colorsRef.current;
87
+ const next = [...cur];
88
+ if (index == null || index < 0 || index > next.length) next.push(up);
89
+ else next.splice(index, 0, up);
90
+ colorsRef.current = next;
91
+ setDynColors(next);
92
+ },
93
+ removeColor: (hex) => {
94
+ const target = norm(hex);
95
+ const cur = colorsRef.current ?? [];
96
+ const next = cur.filter((c) => norm(c) !== target);
97
+ colorsRef.current = next;
98
+ setDynColors(next);
99
+ },
100
+ getColors: () => colorsRef.current ?? [],
101
+ select: (color) => {
102
+ selRef.current = norm(color);
103
+ setDynValue(color);
104
+ },
105
+ getSelected: () => selRef.current,
106
+ open: () => setDynOpen(true),
107
+ close: () => setDynOpen(false),
108
+ isOpen: () => openRef.current,
109
+ }),
110
+ [],
111
+ );
112
+
113
+ useEffect(() => {
114
+ openRef.current = dynOpen;
115
+ }, [dynOpen]);
116
+
117
+ useEffect(() => {
118
+ if (open !== undefined) setDynOpen(open);
119
+ }, [open]);
120
+
121
+ useEffect(() => {
122
+ if (value !== undefined) {
123
+ selRef.current = norm(value);
124
+ setDynValue(value);
125
+ }
126
+ }, [value]);
127
+
128
+ useEffect(() => {
129
+ if (colors !== undefined) {
130
+ colorsRef.current = colors;
131
+ setDynColors(colors);
132
+ }
133
+ }, [colors]);
134
+
135
+ useEffect(() => {
136
+ if (!dynOpen) {
137
+ setCoords(null);
138
+ return;
139
+ }
140
+ const el = anchorRef?.current;
141
+ if (el) {
142
+ const r = el.getBoundingClientRect();
143
+ setCoords({ top: Math.round(r.bottom), left: Math.round(r.left) });
144
+ }
145
+ // 对齐 Ext 菜单:点击浮层外部或缩放窗口时关闭;延迟挂载避免捕获打开当次点击。
146
+ const onDocDown = (e: MouseEvent) => {
147
+ const t = e.target as HTMLElement;
148
+ if (!t.closest('.x-color-menu') && !(anchorRef?.current && anchorRef.current.contains(t))) {
149
+ onClose?.();
150
+ setDynOpen(false);
151
+ }
152
+ };
153
+ const close = () => {
154
+ onClose?.();
155
+ setDynOpen(false);
156
+ };
157
+ const t = window.setTimeout(() => {
158
+ document.addEventListener('mousedown', onDocDown);
159
+ window.addEventListener('resize', close);
160
+ }, 0);
161
+ return () => {
162
+ window.clearTimeout(t);
163
+ document.removeEventListener('mousedown', onDocDown);
164
+ window.removeEventListener('resize', close);
165
+ };
166
+ }, [dynOpen, anchorRef, onClose]);
167
+
168
+ if (!dynOpen || !coords) return null;
169
+
170
+ return createPortal(
171
+ <div
172
+ className="x-menu x-menu-floating x-layer x-color-menu x-menu-plain x-menu-nosep"
173
+ style={{ position: 'fixed', visibility: 'visible', zIndex: 15000, top: coords.top, left: coords.left }}
174
+ >
175
+ <a href="#" className="x-menu-focus" tabIndex={-1} onClick={(e) => e.preventDefault()} />
176
+ <ul className="x-menu-list">
177
+ <li className="x-menu-list-item">
178
+ <ColorPalette
179
+ value={dynValue}
180
+ colors={dynColors}
181
+ onSelect={(c) => {
182
+ selRef.current = norm(c);
183
+ onSelect?.(c);
184
+ setDynOpen(false);
185
+ onClose?.();
186
+ }}
187
+ />
188
+ </li>
189
+ </ul>
190
+ </div>,
191
+ document.body,
192
+ );
193
+ });
194
+
195
+ (ColorMenu as React.FC).displayName = 'ColorMenu';