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,170 @@
1
+ import { forwardRef, useImperativeHandle, useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
2
+ import { Button, type ButtonProps } from './Button';
3
+
4
+ export interface ButtonGroupItem extends ButtonProps {
5
+ /** 跨行(table 布局,默认 1) */
6
+ rowspan?: number;
7
+ /** 跨列(table 布局,默认 1) */
8
+ colspan?: number;
9
+ /** 原生 key */
10
+ key?: string | number;
11
+ }
12
+
13
+ /**
14
+ * 运行时句柄(对齐 Ext.ButtonGroup 动态增删改按钮):
15
+ * - addItem(item, index?):在末尾(或指定位置)插入一个按钮项
16
+ * - removeItem(index):删除指定位置的按钮项
17
+ * - updateItem(index, partial):就地更新某项(text/disabled/handler 等)
18
+ * - getItems():读当前按钮项数组
19
+ */
20
+ export interface ButtonGroupHandle {
21
+ addItem: (item: ButtonGroupItem, index?: number) => void;
22
+ removeItem: (index: number) => void;
23
+ updateItem: (index: number, partial: Partial<ButtonGroupItem>) => void;
24
+ getItems: () => ButtonGroupItem[];
25
+ }
26
+
27
+ export interface ButtonGroupProps {
28
+ /** 分组标题;省略则不渲染标题头并加 x-btn-group-notitle */
29
+ title?: string;
30
+ /** 列数(table 布局,默认 3) */
31
+ columns?: number;
32
+ /** 按钮项(复用 Button 两层 table DOM) */
33
+ items?: ButtonGroupItem[];
34
+ /** 根宽(默认自适应内容) */
35
+ width?: number | string;
36
+ className?: string;
37
+ style?: CSSProperties;
38
+ testId?: string;
39
+ children?: ReactNode;
40
+ }
41
+
42
+ /**
43
+ * 复刻 Ext.ButtonGroup(ExtJS 3.2.1 src/widgets/ButtonGroup.js)。
44
+ * 本质是 frame:true 的 Panel:9 宫格圆角结构,仅 baseCls 由 x-panel 替换为 x-btn-group;
45
+ * 头部为 x-btn-group-header(含 x-btn-group-header-text);body 为 table 布局的 x-btn-group-body,
46
+ * 按 columns 将按钮排入单元格(支持每项 rowspan/colspan)。无标题时根加 x-btn-group-notitle。
47
+ */
48
+ export const ButtonGroup = forwardRef<ButtonGroupHandle, ButtonGroupProps>(
49
+ ({
50
+ title,
51
+ columns = 3,
52
+ items = [],
53
+ width,
54
+ className,
55
+ style,
56
+ testId,
57
+ children,
58
+ }: ButtonGroupProps, ref) => {
59
+ // 单源数组法:items 为唯一真相,句柄直接基于 prev 纯函数改写
60
+ const [dynItems, setDynItems] = useState<ButtonGroupItem[]>(() => items);
61
+ const itemsRef = useRef(dynItems);
62
+ itemsRef.current = dynItems;
63
+
64
+ useImperativeHandle(
65
+ ref,
66
+ () => ({
67
+ addItem: (item: ButtonGroupItem, index?: number) =>
68
+ setDynItems((prev) => {
69
+ const next = [...prev];
70
+ if (index === undefined || index >= next.length) next.push(item);
71
+ else next.splice(index, 0, item);
72
+ return next;
73
+ }),
74
+ removeItem: (index: number) => setDynItems((prev) => prev.filter((_, i) => i !== index)),
75
+ updateItem: (index: number, partial: Partial<ButtonGroupItem>) =>
76
+ setDynItems((prev) => prev.map((it, i) => (i === index ? { ...it, ...partial } : it))),
77
+ getItems: () => itemsRef.current,
78
+ }),
79
+ [],
80
+ );
81
+
82
+ useEffect(() => setDynItems(items), [items]);
83
+
84
+ const rootClass = [
85
+ 'x-btn-group',
86
+ !title ? 'x-btn-group-notitle' : '',
87
+ className ?? '',
88
+ ]
89
+ .filter(Boolean)
90
+ .join(' ');
91
+
92
+ const header = title ? (
93
+ <div className="x-btn-group-header">
94
+ <span className="x-btn-group-header-text">{title}</span>
95
+ </div>
96
+ ) : null;
97
+
98
+ // table 布局:顺序流式排布,每行 columns 个单元格(默认 1 列宽)。
99
+ // 支持每项 rowspan/colspan(对齐 Ext.layout.TableLayout 的单元格合并语义)。
100
+ const cols = Math.max(1, columns);
101
+ const rows: ReactNode[][] = [[]];
102
+ let rowCol = 0;
103
+ const cellOf = (it: ButtonGroupItem): ReactNode => {
104
+ const rs = it.rowspan ?? 1;
105
+ const cs = it.colspan ?? 1;
106
+ return (
107
+ <td
108
+ className="x-btn-group-cell"
109
+ rowSpan={rs > 1 ? rs : undefined}
110
+ colSpan={cs > 1 ? cs : undefined}
111
+ key={it.key ?? it.text}
112
+ >
113
+ <Button {...it} />
114
+ </td>
115
+ );
116
+ };
117
+ for (const it of dynItems) {
118
+ if (rowCol >= cols) {
119
+ rows.push([]);
120
+ rowCol = 0;
121
+ }
122
+ rows[rows.length - 1].push(cellOf(it));
123
+ rowCol += it.colspan ?? 1;
124
+ }
125
+ // 末行不足列数时补空单元格,保持表格宽度一致
126
+ while (rowCol < cols) {
127
+ rows[rows.length - 1].push(<td key={`pad-${rowCol}`} />);
128
+ rowCol++;
129
+ }
130
+
131
+ return (
132
+ <div
133
+ className={rootClass}
134
+ // Ext 经 onAfterLayout 给 x-btn-group 设精确宽度(贴合内容);react 无运行时,
135
+ // 用 inline-block 收缩到内容宽度,复刻「紧凑分组框」外观(避免 block 撑满父容器导致空白像素差)。
136
+ style={{ display: 'inline-block', width, ...style }}
137
+ data-testid={testId}
138
+ >
139
+ <div className="x-btn-group-tl">
140
+ <div className="x-btn-group-tr">
141
+ <div className="x-btn-group-tc">{header}</div>
142
+ </div>
143
+ </div>
144
+ <div className="x-btn-group-bwrap">
145
+ <div className="x-btn-group-ml">
146
+ <div className="x-btn-group-mr">
147
+ <div className="x-btn-group-mc">
148
+ <div className="x-btn-group-body">
149
+ {children ?? (
150
+ <table cellPadding={0} cellSpacing={0}>
151
+ <tbody>{rows.map((r, i) => <tr key={i}>{r}</tr>)}</tbody>
152
+ </table>
153
+ )}
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ <div className="x-btn-group-bl" style={{ height: 3 }}>
159
+ <div className="x-btn-group-br">
160
+ <div className="x-btn-group-bc" style={{ height: 3, fontSize: 0, lineHeight: 0 }} />
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ );
166
+ });
167
+
168
+ ButtonGroup.displayName = 'ButtonGroup';
169
+
170
+ export default ButtonGroup;
@@ -0,0 +1,431 @@
1
+ import {
2
+ forwardRef,
3
+ useImperativeHandle,
4
+ useRef,
5
+ useState,
6
+ useEffect,
7
+ type CSSProperties,
8
+ type RefObject,
9
+ } from 'react';
10
+ import { createPortal } from 'react-dom';
11
+ import { TriggerField, type TriggerFieldHandle } from './Form';
12
+ import { DatePicker } from './DatePicker';
13
+ import { Button } from './Button';
14
+
15
+ /* ------------------------------------------------------------------ */
16
+ /* CalendarPicker —— 组合原生积木实现的日期范围选择器(单面板双日历) */
17
+ /* */
18
+ /* 保真契约(DOM/class 全部为原版 Ext 3.2.1 真实存在的 class,零新增 CSS): */
19
+ /* - 字段层用两个无触发器的 TriggerField(hideTrigger)拼成一体范围框: */
20
+ /* span.x-form-field-wrap.x-form-field-trigger-wrap */
21
+ /* > input.x-form-text(点字段任意处即弹面板,无独立图标) */
22
+ /* - 浮层为“单面板双日历”:复用 Ext.menu.DateMenu 的 DOM 结构与定位 */
23
+ /* div.x-menu.x-menu-floating.x-layer.x-date-menu */
24
+ /* > ul.x-menu-list(flex 并排,右格加分隔线,对齐 antd 双月面板)*/
25
+ /* > li.x-menu-list-item × 2 */
26
+ /* > div.x-date-picker(左=起点,右=终点;内部 DatePicker */
27
+ /* 已逐像素保真,含表头/日历网格/月份选择浮层/今天按钮) */
28
+ /* */
29
+ /* 值结构:{ start: string, end: string }(均为 format 格式字符串)。 */
30
+ /* 标准 Ext 3.2.1 无日期范围选择器;社区变体常见“单面板双日历”形态, */
31
+ /* 本组件以原版 x-date-picker 积木拼出该能力,DOM 全部为原版已有 class, */
32
+ /* 符合“不新增/不修改 CSS”的开发标准。 */
33
+ /* ------------------------------------------------------------------ */
34
+
35
+ /** 对齐 Ext Date.format 的最小子集(Y/m/d/H/i/s),默认 'm/d/Y'。 */
36
+ function pad(n: number, w = 2): string {
37
+ return String(n).padStart(w, '0');
38
+ }
39
+ function formatDate(d: Date, fmt = 'm/d/Y'): string {
40
+ const map: Record<string, string> = {
41
+ Y: String(d.getFullYear()),
42
+ y: String(d.getFullYear()).slice(-2),
43
+ m: pad(d.getMonth() + 1),
44
+ d: pad(d.getDate()),
45
+ H: pad(d.getHours()),
46
+ i: pad(d.getMinutes()),
47
+ s: pad(d.getSeconds()),
48
+ };
49
+ return fmt.replace(/[YymdHis]/g, (t) => map[t] ?? t);
50
+ }
51
+ /** 按 format 的 token 顺序解析;仅支持数字段(对齐 DateField 默认行为)。 */
52
+ function parseDate(str: string, fmt = 'm/d/Y'): Date | null {
53
+ const order = fmt.match(/[Ymd]/g) ?? ['m', 'd', 'Y'];
54
+ const parts = str.split(/[^\d]+/).filter(Boolean).map(Number);
55
+ if (parts.length < 3) return null;
56
+ let y: number | undefined, m: number | undefined, d: number | undefined;
57
+ order.forEach((t, i) => {
58
+ if (t === 'Y') y = parts[i];
59
+ else if (t === 'm') m = parts[i];
60
+ else if (t === 'd') d = parts[i];
61
+ });
62
+ if (y === undefined || m === undefined || d === undefined) return null;
63
+ if (y < 100) y += 2000;
64
+ const dt = new Date(y, m - 1, d);
65
+ return Number.isNaN(dt.getTime()) ? null : dt;
66
+ }
67
+
68
+ /** 范围值:起止两个日期,均为 format 格式字符串。空串表示未选。 */
69
+ export interface CalendarRangeValue {
70
+ start: string;
71
+ end: string;
72
+ }
73
+
74
+ /* ----------------- 单面板双日历浮层(复用 DateMenu 结构) ----------------- */
75
+
76
+ interface RangeMenuProps {
77
+ open?: boolean;
78
+ anchorRef?: RefObject<HTMLElement>;
79
+ /** 已提交到输入框的起/止值(仅用于面板打开时初始化草稿)。 */
80
+ startValue?: Date | null;
81
+ endValue?: Date | null;
82
+ startDay?: number;
83
+ showToday?: boolean;
84
+ minDate?: Date | null;
85
+ maxDate?: Date | null;
86
+ disabledDays?: number[];
87
+ onClose: () => void;
88
+ /** 点击底部“确定”:把面板内草稿(start/end)提交到输入框并关闭面板。 */
89
+ onApply?: (start: Date | null, end: Date | null) => void;
90
+ }
91
+
92
+ function RangeMenu({
93
+ open,
94
+ anchorRef,
95
+ startValue,
96
+ endValue,
97
+ startDay,
98
+ showToday,
99
+ minDate,
100
+ maxDate,
101
+ disabledDays,
102
+ onClose,
103
+ onApply,
104
+ }: RangeMenuProps) {
105
+ const [dynOpen, setDynOpen] = useState<boolean>(open ?? false);
106
+ const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
107
+ // 面板内草稿选择(独立于已提交值):选日期只更新草稿高亮,不写入输入框。
108
+ const [draftStart, setDraftStart] = useState<Date | null>(startValue ?? null);
109
+ const [draftEnd, setDraftEnd] = useState<Date | null>(endValue ?? null);
110
+
111
+ useEffect(() => {
112
+ if (open !== undefined) setDynOpen(open);
113
+ }, [open]);
114
+
115
+ useEffect(() => {
116
+ if (!dynOpen) {
117
+ setCoords(null);
118
+ return;
119
+ }
120
+ // 每次打开用已提交值初始化草稿(DatePicker 卸载重建,value 作初值高亮)。
121
+ setDraftStart(startValue ?? null);
122
+ setDraftEnd(endValue ?? null);
123
+ const el = anchorRef?.current;
124
+ if (el) {
125
+ const r = el.getBoundingClientRect();
126
+ setCoords({ top: Math.round(r.bottom), left: Math.round(r.left) });
127
+ }
128
+ const onDocDown = (e: MouseEvent) => {
129
+ const t = e.target as HTMLElement;
130
+ if (
131
+ !t.closest('.x-date-menu') &&
132
+ !t.closest('.x-date-picker') &&
133
+ !(anchorRef?.current && anchorRef.current.contains(t))
134
+ ) {
135
+ onClose();
136
+ setDynOpen(false);
137
+ }
138
+ };
139
+ const close = () => {
140
+ onClose();
141
+ setDynOpen(false);
142
+ };
143
+ const t = window.setTimeout(() => {
144
+ document.addEventListener('mousedown', onDocDown);
145
+ window.addEventListener('resize', close);
146
+ }, 0);
147
+ return () => {
148
+ window.clearTimeout(t);
149
+ document.removeEventListener('mousedown', onDocDown);
150
+ window.removeEventListener('resize', close);
151
+ };
152
+ }, [dynOpen, anchorRef, onClose, startValue, endValue]);
153
+
154
+ if (!dynOpen || !coords) return null;
155
+
156
+ return createPortal(
157
+ <div
158
+ className="x-menu x-menu-floating x-layer x-date-menu"
159
+ style={{
160
+ position: 'fixed',
161
+ visibility: 'visible',
162
+ zIndex: 15000,
163
+ top: coords.top,
164
+ left: coords.left,
165
+ background: '#fff',
166
+ border: '1px solid #f0f0f0',
167
+ borderRadius: 8,
168
+ boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
169
+ padding: 0,
170
+ }}
171
+ onClick={(e) => e.stopPropagation()}
172
+ >
173
+ <a href="#" className="x-menu-focus" tabIndex={-1} onClick={(e) => e.preventDefault()} />
174
+ <ul
175
+ className="x-menu-list"
176
+ style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-start', margin: 0, padding: 2, gap: 0 }}
177
+ >
178
+ <li
179
+ className="x-menu-list-item"
180
+ style={{ display: 'block', listStyle: 'none', margin: 0, padding: 1 }}
181
+ >
182
+ <DatePicker
183
+ className="x-menu-date-item"
184
+ style={{ margin: 0, border: 'none' }}
185
+ value={draftStart ?? undefined}
186
+ startDay={startDay}
187
+ showToday={showToday}
188
+ minDate={minDate}
189
+ maxDate={maxDate}
190
+ disabledDays={disabledDays}
191
+ onSelect={(d) => setDraftStart(d)}
192
+ />
193
+ </li>
194
+ <li
195
+ className="x-menu-list-item"
196
+ style={{
197
+ display: 'block',
198
+ listStyle: 'none',
199
+ margin: 0,
200
+ padding: '1px 1px 1px 6px',
201
+ borderLeft: '1px solid #f0f0f0',
202
+ }}
203
+ >
204
+ <DatePicker
205
+ className="x-menu-date-item"
206
+ style={{ margin: 0, border: 'none' }}
207
+ value={draftEnd ?? undefined}
208
+ startDay={startDay}
209
+ showToday={showToday}
210
+ minDate={minDate}
211
+ maxDate={maxDate}
212
+ disabledDays={disabledDays}
213
+ onSelect={(d) => setDraftEnd(d)}
214
+ />
215
+ </li>
216
+ </ul>
217
+ <div
218
+ style={{
219
+ display: 'flex',
220
+ justifyContent: 'flex-end',
221
+ alignItems: 'center',
222
+ gap: 3,
223
+ padding: 5,
224
+ borderTop: '1px solid #f0f0f0',
225
+ }}
226
+ >
227
+ <Button
228
+ text="确定"
229
+ disabled={!draftStart || !draftEnd}
230
+ handler={() => onApply?.(draftStart, draftEnd)}
231
+ />
232
+ </div>
233
+ </div>,
234
+ document.body,
235
+ );
236
+ }
237
+
238
+ /* ----------------------------- 范围选择器 ----------------------------- */
239
+
240
+ export interface CalendarPickerProps {
241
+ id?: string;
242
+ /** 起始字段名(表单提交用)。 */
243
+ startName?: string;
244
+ /** 结束字段名(表单提交用)。 */
245
+ endName?: string;
246
+ /** 受控范围值 {start,end}。提供时作为唯一来源(受控模式),由 onChange 回写。 */
247
+ value?: CalendarRangeValue;
248
+ /** 非受控初始范围值(对齐 Ext 默认 value 作为初值)。 */
249
+ defaultValue?: CalendarRangeValue;
250
+ /** 显示格式,对齐 Ext format(默认 'm/d/Y')。 */
251
+ format?: string;
252
+ /** 单侧字段宽度(像素),透传给内部两个 TriggerField。 */
253
+ width?: number;
254
+ disabled?: boolean;
255
+ /** 文本框是否可编辑(对齐 Ext editable,默认 true)。 */
256
+ editable?: boolean;
257
+ /** 最小可选日期(格式字符串,对齐 Ext minValue),作用于起止两侧。 */
258
+ minValue?: string;
259
+ /** 最大可选日期(格式字符串,对齐 Ext maxValue),作用于起止两侧。 */
260
+ maxValue?: string;
261
+ /** 禁用的星期索引数组,如 [0,6](对齐 Ext disabledDays)。 */
262
+ disabledDays?: number[];
263
+ /** 一周起始日,0=周日(对齐 Ext startDay)。 */
264
+ startDay?: number;
265
+ /** 是否显示底部“今天”按钮(范围选择器默认不显示,对齐 antd 双月面板观感)。 */
266
+ showToday?: boolean;
267
+ /** 未选择时的占位提示。 */
268
+ emptyText?: string;
269
+ placeholder?: string;
270
+ /** 起止之间的分隔符,默认“ 至 ”。 */
271
+ separator?: string;
272
+ /** 范围变化回调(参数为 {start,end})。 */
273
+ onChange?: (value: CalendarRangeValue) => void;
274
+ /** 从日历面板选中日期后的回调(对齐 Ext select,参数为最新 {start,end})。 */
275
+ onSelect?: (value: CalendarRangeValue) => void;
276
+ cls?: string;
277
+ style?: CSSProperties;
278
+ }
279
+
280
+ export interface CalendarPickerHandle {
281
+ getValue: () => CalendarRangeValue;
282
+ setValue: (v: CalendarRangeValue) => void;
283
+ getValueDate: () => { start: Date | null; end: Date | null };
284
+ setValueDate: (start: Date | null, end: Date | null) => void;
285
+ getStartValue: () => string;
286
+ getEndValue: () => string;
287
+ setStartValue: (v: string) => void;
288
+ setEndValue: (v: string) => void;
289
+ focus: () => void;
290
+ }
291
+
292
+ const EMPTY: CalendarRangeValue = { start: '', end: '' };
293
+
294
+ export const CalendarPicker = forwardRef<CalendarPickerHandle, CalendarPickerProps>(
295
+ function CalendarPicker(
296
+ {
297
+ id,
298
+ startName,
299
+ endName,
300
+ value,
301
+ defaultValue,
302
+ format = 'm/d/Y',
303
+ width,
304
+ disabled,
305
+ editable = true,
306
+ minValue,
307
+ maxValue,
308
+ disabledDays,
309
+ startDay,
310
+ showToday = false,
311
+ emptyText,
312
+ placeholder,
313
+ separator = ' 至 ',
314
+ onChange,
315
+ onSelect,
316
+ cls,
317
+ style,
318
+ },
319
+ ref,
320
+ ) {
321
+ const isControlled = value !== undefined;
322
+ const [internal, setInternal] = useState<CalendarRangeValue>(defaultValue ?? value ?? EMPTY);
323
+ const current = isControlled ? (value as CalendarRangeValue) : internal;
324
+
325
+ const [open, setOpen] = useState(false);
326
+ const wrapRef = useRef<HTMLSpanElement>(null);
327
+ const startRef = useRef<TriggerFieldHandle>(null);
328
+
329
+ const minD = minValue ? parseDate(minValue, format) ?? undefined : undefined;
330
+ const maxD = maxValue ? parseDate(maxValue, format) ?? undefined : undefined;
331
+ const startD = current.start ? parseDate(current.start, format) : null;
332
+ const endD = current.end ? parseDate(current.end, format) : null;
333
+ // 范围字段默认单侧宽度(隐藏触发器后 input 占满整宽,需给一个基准宽度避免塌陷)。
334
+ const fieldWidth = width ?? 120;
335
+
336
+ const commit = (next: CalendarRangeValue, picked: boolean) => {
337
+ if (!isControlled) setInternal(next);
338
+ onChange?.(next);
339
+ if (picked) onSelect?.(next);
340
+ };
341
+
342
+ // 仅点「确定」时把面板内草稿提交到输入框并关闭面板(选日期本身不提交)。
343
+ const applyRange = (s: Date | null, e: Date | null) => {
344
+ commit(
345
+ {
346
+ start: s ? formatDate(s, format) : '',
347
+ end: e ? formatDate(e, format) : '',
348
+ },
349
+ true,
350
+ );
351
+ setOpen(false);
352
+ };
353
+
354
+ useImperativeHandle(
355
+ ref,
356
+ () => ({
357
+ getValue: () => current,
358
+ setValue: (v) => commit(v, false),
359
+ getValueDate: () => ({
360
+ start: current.start ? parseDate(current.start, format) : null,
361
+ end: current.end ? parseDate(current.end, format) : null,
362
+ }),
363
+ setValueDate: (s, e) =>
364
+ commit(
365
+ { start: s ? formatDate(s, format) : '', end: e ? formatDate(e, format) : '' },
366
+ false,
367
+ ),
368
+ getStartValue: () => current.start,
369
+ getEndValue: () => current.end,
370
+ setStartValue: (v) => commit({ ...current, start: v }, false),
371
+ setEndValue: (v) => commit({ ...current, end: v }, false),
372
+ focus: () => startRef.current?.focus(),
373
+ }),
374
+ [current, isControlled, format],
375
+ );
376
+
377
+ return (
378
+ <span
379
+ id={id}
380
+ ref={wrapRef}
381
+ className={cls}
382
+ style={{ position: 'relative', display: 'inline-block', ...style }}
383
+ onClick={() => !disabled && setOpen(true)}
384
+ >
385
+ <TriggerField
386
+ ref={startRef}
387
+ name={startName}
388
+ value={current.start}
389
+ width={fieldWidth}
390
+ disabled={disabled}
391
+ editable={editable}
392
+ hideTrigger
393
+ emptyText={emptyText}
394
+ placeholder={placeholder}
395
+ onChange={(v) => commit({ ...current, start: v }, false)}
396
+ />
397
+ <span
398
+ style={{ display: 'inline-block', verticalAlign: 'middle', color: '#444', padding: '0 4px' }}
399
+ >
400
+ {separator}
401
+ </span>
402
+ <TriggerField
403
+ name={endName}
404
+ value={current.end}
405
+ width={fieldWidth}
406
+ disabled={disabled}
407
+ editable={editable}
408
+ hideTrigger
409
+ emptyText={emptyText}
410
+ placeholder={placeholder}
411
+ onChange={(v) => commit({ ...current, end: v }, false)}
412
+ />
413
+ <RangeMenu
414
+ open={open}
415
+ anchorRef={wrapRef}
416
+ startValue={startD}
417
+ endValue={endD}
418
+ startDay={startDay}
419
+ showToday={showToday}
420
+ minDate={minD}
421
+ maxDate={maxD}
422
+ disabledDays={disabledDays}
423
+ onClose={() => setOpen(false)}
424
+ onApply={(s, e) => applyRange(s, e)}
425
+ />
426
+ </span>
427
+ );
428
+ },
429
+ );
430
+
431
+ CalendarPicker.displayName = 'CalendarPicker';