@teamix-evo/ui 0.1.1 → 0.3.0

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 (295) hide show
  1. package/README.md +184 -184
  2. package/manifest.json +680 -492
  3. package/package.json +20 -10
  4. package/src/components/accordion/accordion.meta.md +5 -4
  5. package/src/components/accordion/accordion.stories.tsx +14 -9
  6. package/src/components/accordion/accordion.tsx +104 -8
  7. package/src/components/affix/affix.meta.md +20 -2
  8. package/src/components/affix/affix.stories.tsx +102 -25
  9. package/src/components/affix/affix.tsx +79 -9
  10. package/src/components/alert/alert.meta.md +44 -13
  11. package/src/components/alert/alert.stories.tsx +66 -21
  12. package/src/components/alert/alert.tsx +81 -34
  13. package/src/components/alert-dialog/alert-dialog.meta.md +61 -16
  14. package/src/components/alert-dialog/alert-dialog.stories.tsx +145 -3
  15. package/src/components/alert-dialog/alert-dialog.tsx +60 -13
  16. package/src/components/anchor/anchor.meta.md +8 -3
  17. package/src/components/anchor/anchor.stories.tsx +3 -3
  18. package/src/components/anchor/anchor.tsx +2 -2
  19. package/src/components/app/app.meta.md +9 -4
  20. package/src/components/app/app.stories.tsx +9 -7
  21. package/src/components/aspect-ratio/aspect-ratio.meta.md +4 -3
  22. package/src/components/aspect-ratio/aspect-ratio.stories.tsx +3 -3
  23. package/src/components/auto-complete/auto-complete.meta.md +14 -6
  24. package/src/components/auto-complete/auto-complete.stories.tsx +47 -4
  25. package/src/components/auto-complete/auto-complete.tsx +119 -71
  26. package/src/components/avatar/avatar.meta.md +6 -7
  27. package/src/components/avatar/avatar.stories.tsx +21 -3
  28. package/src/components/avatar/avatar.tsx +24 -23
  29. package/src/components/badge/badge.meta.md +10 -9
  30. package/src/components/badge/badge.stories.tsx +2 -2
  31. package/src/components/badge/badge.tsx +9 -15
  32. package/src/components/breadcrumb/breadcrumb.meta.md +27 -7
  33. package/src/components/breadcrumb/breadcrumb.stories.tsx +127 -4
  34. package/src/components/breadcrumb/breadcrumb.tsx +22 -8
  35. package/src/components/button/button.meta.md +258 -21
  36. package/src/components/button/button.stories.tsx +549 -41
  37. package/src/components/button/button.tsx +335 -33
  38. package/src/components/button/demo/as-child.tsx +24 -0
  39. package/src/components/button/demo/basic.tsx +8 -0
  40. package/src/components/button/demo/block.tsx +16 -0
  41. package/src/components/button/demo/loading.tsx +19 -0
  42. package/src/components/button/demo/shapes.tsx +18 -0
  43. package/src/components/button/demo/sizes.tsx +19 -0
  44. package/src/components/button/demo/variants.tsx +19 -0
  45. package/src/components/button/demo/with-icon.tsx +20 -0
  46. package/src/components/calendar/calendar.meta.md +13 -3
  47. package/src/components/calendar/calendar.stories.tsx +6 -6
  48. package/src/components/calendar/calendar.tsx +73 -8
  49. package/src/components/card/card.meta.md +27 -5
  50. package/src/components/card/card.stories.tsx +42 -3
  51. package/src/components/card/card.tsx +146 -63
  52. package/src/components/carousel/carousel.meta.md +4 -3
  53. package/src/components/carousel/carousel.stories.tsx +11 -6
  54. package/src/components/cascader/cascader.meta.md +47 -17
  55. package/src/components/cascader/cascader.stories.tsx +22 -10
  56. package/src/components/cascader/cascader.tsx +428 -85
  57. package/src/components/checkbox/checkbox.meta.md +75 -7
  58. package/src/components/checkbox/checkbox.stories.tsx +161 -3
  59. package/src/components/checkbox/checkbox.tsx +77 -9
  60. package/src/components/collapsible/collapsible.meta.md +14 -6
  61. package/src/components/collapsible/collapsible.stories.tsx +10 -2
  62. package/src/components/collapsible/collapsible.tsx +93 -6
  63. package/src/components/color-picker/color-picker.meta.md +12 -7
  64. package/src/components/color-picker/color-picker.stories.tsx +86 -7
  65. package/src/components/color-picker/color-picker.tsx +20 -9
  66. package/src/components/command/command.meta.md +29 -13
  67. package/src/components/command/command.stories.tsx +4 -4
  68. package/src/components/command/command.tsx +19 -8
  69. package/src/components/context-menu/context-menu.meta.md +11 -8
  70. package/src/components/context-menu/context-menu.stories.tsx +11 -3
  71. package/src/components/context-menu/context-menu.tsx +21 -8
  72. package/src/components/data-table/data-table.meta.md +6 -5
  73. package/src/components/data-table/data-table.stories.tsx +13 -6
  74. package/src/components/data-table/data-table.tsx +2 -2
  75. package/src/components/date-picker/date-picker.meta.md +88 -19
  76. package/src/components/date-picker/date-picker.stories.tsx +55 -5
  77. package/src/components/date-picker/date-picker.tsx +1489 -91
  78. package/src/components/descriptions/descriptions.meta.md +10 -5
  79. package/src/components/descriptions/descriptions.stories.tsx +3 -3
  80. package/src/components/descriptions/descriptions.tsx +22 -14
  81. package/src/components/dialog/dialog.meta.md +76 -13
  82. package/src/components/dialog/dialog.stories.tsx +182 -20
  83. package/src/components/dialog/dialog.tsx +67 -15
  84. package/src/components/dialog/imperative.tsx +252 -0
  85. package/src/components/drawer/drawer.meta.md +33 -34
  86. package/src/components/drawer/drawer.stories.tsx +29 -12
  87. package/src/components/drawer/drawer.tsx +22 -113
  88. package/src/components/dropdown-menu/dropdown-menu.meta.md +78 -10
  89. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +88 -2
  90. package/src/components/dropdown-menu/dropdown-menu.tsx +24 -10
  91. package/src/components/ellipsis/ellipsis.meta.md +87 -0
  92. package/src/components/ellipsis/ellipsis.stories.tsx +72 -0
  93. package/src/components/ellipsis/ellipsis.tsx +153 -0
  94. package/src/components/empty/empty.meta.md +9 -4
  95. package/src/components/empty/empty.stories.tsx +4 -4
  96. package/src/components/empty/empty.tsx +10 -3
  97. package/src/components/field/field.meta.md +47 -9
  98. package/src/components/field/field.stories.tsx +385 -5
  99. package/src/components/field/field.tsx +263 -35
  100. package/src/components/filter-bar/filter-bar.meta.md +92 -0
  101. package/src/components/filter-bar/filter-bar.stories.tsx +1083 -0
  102. package/src/components/filter-bar/filter-bar.tsx +568 -0
  103. package/src/components/flex/flex.meta.md +54 -6
  104. package/src/components/flex/flex.stories.tsx +107 -20
  105. package/src/components/flex/flex.tsx +27 -4
  106. package/src/components/float-button/float-button.meta.md +8 -3
  107. package/src/components/float-button/float-button.stories.tsx +9 -7
  108. package/src/components/float-button/float-button.tsx +1 -1
  109. package/src/components/form/form.meta.md +39 -17
  110. package/src/components/form/form.stories.tsx +350 -3
  111. package/src/components/form/form.tsx +101 -35
  112. package/src/components/grid/grid.meta.md +7 -2
  113. package/src/components/grid/grid.stories.tsx +6 -4
  114. package/src/components/hover-card/hover-card.meta.md +20 -9
  115. package/src/components/hover-card/hover-card.stories.tsx +34 -5
  116. package/src/components/hover-card/hover-card.tsx +51 -13
  117. package/src/components/icon/DEVELOPMENT.md +809 -0
  118. package/src/components/icon/icon.meta.md +170 -0
  119. package/src/components/icon/icon.stories.tsx +344 -0
  120. package/src/components/icon/icon.tsx +248 -0
  121. package/src/components/image/image.meta.md +9 -4
  122. package/src/components/image/image.stories.tsx +3 -3
  123. package/src/components/image/image.tsx +6 -4
  124. package/src/components/input/demo/basic.tsx +12 -0
  125. package/src/components/input/demo/clearable.tsx +21 -0
  126. package/src/components/input/demo/show-count.tsx +18 -0
  127. package/src/components/input/demo/sizes.tsx +15 -0
  128. package/src/components/input/input.meta.md +39 -33
  129. package/src/components/input/input.stories.tsx +62 -35
  130. package/src/components/input/input.tsx +97 -98
  131. package/src/components/input-group/input-group.meta.md +54 -22
  132. package/src/components/input-group/input-group.stories.tsx +49 -16
  133. package/src/components/input-group/input-group.tsx +44 -8
  134. package/src/components/input-number/input-number.meta.md +64 -7
  135. package/src/components/input-number/input-number.stories.tsx +46 -8
  136. package/src/components/input-number/input-number.tsx +99 -26
  137. package/src/components/input-otp/input-otp.meta.md +4 -3
  138. package/src/components/input-otp/input-otp.stories.tsx +3 -3
  139. package/src/components/input-otp/input-otp.tsx +1 -1
  140. package/src/components/item/item.meta.md +8 -3
  141. package/src/components/item/item.stories.tsx +8 -5
  142. package/src/components/item/item.tsx +7 -6
  143. package/src/components/kbd/kbd.meta.md +13 -4
  144. package/src/components/kbd/kbd.stories.tsx +4 -4
  145. package/src/components/kbd/kbd.tsx +10 -5
  146. package/src/components/label/label.meta.md +18 -10
  147. package/src/components/label/label.stories.tsx +64 -6
  148. package/src/components/label/label.tsx +91 -19
  149. package/src/components/masonry/masonry.meta.md +8 -3
  150. package/src/components/masonry/masonry.stories.tsx +7 -5
  151. package/src/components/masonry/masonry.tsx +1 -0
  152. package/src/components/mentions/mentions.meta.md +36 -6
  153. package/src/components/mentions/mentions.stories.tsx +120 -6
  154. package/src/components/mentions/mentions.tsx +11 -5
  155. package/src/components/menubar/menubar.meta.md +30 -12
  156. package/src/components/menubar/menubar.stories.tsx +62 -2
  157. package/src/components/menubar/menubar.tsx +9 -9
  158. package/src/components/native-select/native-select.meta.md +8 -3
  159. package/src/components/native-select/native-select.stories.tsx +8 -5
  160. package/src/components/native-select/native-select.tsx +1 -1
  161. package/src/components/navigation-menu/navigation-menu.meta.md +19 -9
  162. package/src/components/navigation-menu/navigation-menu.stories.tsx +112 -9
  163. package/src/components/navigation-menu/navigation-menu.tsx +8 -4
  164. package/src/components/notification/notification.meta.md +52 -10
  165. package/src/components/notification/notification.stories.tsx +11 -9
  166. package/src/components/notification/notification.tsx +36 -21
  167. package/src/components/page-header/DEVELOPMENT.md +842 -0
  168. package/src/components/page-header/page-header.meta.md +208 -0
  169. package/src/components/page-header/page-header.stories.tsx +421 -0
  170. package/src/components/page-header/page-header.tsx +281 -0
  171. package/src/components/pagination/pagination.meta.md +140 -37
  172. package/src/components/pagination/pagination.stories.tsx +232 -10
  173. package/src/components/pagination/pagination.tsx +355 -63
  174. package/src/components/popconfirm/popconfirm.meta.md +9 -4
  175. package/src/components/popconfirm/popconfirm.stories.tsx +3 -4
  176. package/src/components/popconfirm/popconfirm.tsx +2 -2
  177. package/src/components/popover/popover.meta.md +62 -5
  178. package/src/components/popover/popover.stories.tsx +83 -7
  179. package/src/components/popover/popover.tsx +77 -28
  180. package/src/components/progress/progress.meta.md +38 -6
  181. package/src/components/progress/progress.stories.tsx +3 -3
  182. package/src/components/progress/progress.tsx +24 -16
  183. package/src/components/radio-group/radio-group.meta.md +79 -7
  184. package/src/components/radio-group/radio-group.stories.tsx +39 -3
  185. package/src/components/radio-group/radio-group.tsx +149 -18
  186. package/src/components/rate/rate.meta.md +35 -4
  187. package/src/components/rate/rate.stories.tsx +13 -5
  188. package/src/components/rate/rate.tsx +37 -10
  189. package/src/components/resizable/resizable.meta.md +7 -4
  190. package/src/components/resizable/resizable.stories.tsx +6 -6
  191. package/src/components/resizable/resizable.tsx +1 -1
  192. package/src/components/result/result.meta.md +7 -2
  193. package/src/components/result/result.stories.tsx +4 -8
  194. package/src/components/result/result.tsx +24 -15
  195. package/src/components/scroll-area/scroll-area.meta.md +4 -3
  196. package/src/components/scroll-area/scroll-area.stories.tsx +12 -4
  197. package/src/components/scroll-area/scroll-area.tsx +3 -3
  198. package/src/components/segmented/segmented.meta.md +7 -4
  199. package/src/components/segmented/segmented.stories.tsx +37 -8
  200. package/src/components/segmented/segmented.tsx +15 -7
  201. package/src/components/select/select.meta.md +197 -52
  202. package/src/components/select/select.stories.tsx +238 -63
  203. package/src/components/select/select.tsx +718 -171
  204. package/src/components/separator/separator.meta.md +4 -3
  205. package/src/components/separator/separator.stories.tsx +3 -3
  206. package/src/components/separator/separator.tsx +3 -7
  207. package/src/components/sheet/sheet.meta.md +32 -16
  208. package/src/components/sheet/sheet.stories.tsx +116 -10
  209. package/src/components/sheet/sheet.tsx +116 -29
  210. package/src/components/sidebar/sidebar.meta.md +37 -18
  211. package/src/components/sidebar/sidebar.stories.tsx +701 -29
  212. package/src/components/sidebar/sidebar.tsx +615 -142
  213. package/src/components/skeleton/skeleton.meta.md +4 -5
  214. package/src/components/skeleton/skeleton.stories.tsx +4 -4
  215. package/src/components/skeleton/skeleton.tsx +7 -7
  216. package/src/components/slider/slider.meta.md +57 -5
  217. package/src/components/slider/slider.stories.tsx +58 -6
  218. package/src/components/slider/slider.tsx +154 -13
  219. package/src/components/sonner/sonner.meta.md +58 -7
  220. package/src/components/sonner/sonner.stories.tsx +78 -5
  221. package/src/components/sonner/sonner.tsx +137 -8
  222. package/src/components/spinner/spinner.meta.md +62 -13
  223. package/src/components/spinner/spinner.stories.tsx +66 -14
  224. package/src/components/spinner/spinner.tsx +111 -9
  225. package/src/components/statistic/statistic.meta.md +7 -2
  226. package/src/components/statistic/statistic.stories.tsx +3 -7
  227. package/src/components/statistic/statistic.tsx +5 -6
  228. package/src/components/steps/steps.meta.md +18 -4
  229. package/src/components/steps/steps.stories.tsx +43 -3
  230. package/src/components/steps/steps.tsx +15 -12
  231. package/src/components/switch/switch.meta.md +51 -5
  232. package/src/components/switch/switch.stories.tsx +6 -6
  233. package/src/components/switch/switch.tsx +109 -41
  234. package/src/components/table/table.meta.md +17 -6
  235. package/src/components/table/table.stories.tsx +10 -5
  236. package/src/components/table/table.tsx +4 -4
  237. package/src/components/tabs/tabs.meta.md +38 -25
  238. package/src/components/tabs/tabs.stories.tsx +111 -25
  239. package/src/components/tabs/tabs.tsx +125 -54
  240. package/src/components/tag/tag.meta.md +105 -40
  241. package/src/components/tag/tag.stories.tsx +189 -16
  242. package/src/components/tag/tag.tsx +222 -21
  243. package/src/components/textarea/textarea.meta.md +35 -19
  244. package/src/components/textarea/textarea.stories.tsx +32 -6
  245. package/src/components/textarea/textarea.tsx +33 -9
  246. package/src/components/time-picker/time-picker.meta.md +124 -32
  247. package/src/components/time-picker/time-picker.stories.tsx +85 -15
  248. package/src/components/time-picker/time-picker.tsx +913 -61
  249. package/src/components/timeline/timeline.meta.md +14 -6
  250. package/src/components/timeline/timeline.stories.tsx +37 -7
  251. package/src/components/timeline/timeline.tsx +35 -14
  252. package/src/components/toggle/toggle.meta.md +5 -4
  253. package/src/components/toggle/toggle.stories.tsx +4 -4
  254. package/src/components/toggle/toggle.tsx +4 -3
  255. package/src/components/toggle-group/toggle-group.meta.md +5 -4
  256. package/src/components/toggle-group/toggle-group.stories.tsx +3 -3
  257. package/src/components/toggle-group/toggle-group.tsx +2 -2
  258. package/src/components/tooltip/tooltip.meta.md +55 -5
  259. package/src/components/tooltip/tooltip.stories.tsx +42 -5
  260. package/src/components/tooltip/tooltip.tsx +81 -21
  261. package/src/components/tour/tour.meta.md +9 -4
  262. package/src/components/tour/tour.stories.tsx +3 -3
  263. package/src/components/tour/tour.tsx +4 -4
  264. package/src/components/transfer/transfer.meta.md +11 -6
  265. package/src/components/transfer/transfer.stories.tsx +4 -8
  266. package/src/components/transfer/transfer.tsx +28 -21
  267. package/src/components/tree/tree.meta.md +63 -5
  268. package/src/components/tree/tree.stories.tsx +31 -12
  269. package/src/components/tree/tree.tsx +9 -8
  270. package/src/components/tree-select/tree-select.meta.md +59 -8
  271. package/src/components/tree-select/tree-select.stories.tsx +3 -3
  272. package/src/components/tree-select/tree-select.tsx +42 -7
  273. package/src/components/typography/typography.meta.md +61 -14
  274. package/src/components/typography/typography.stories.tsx +12 -11
  275. package/src/components/typography/typography.tsx +43 -28
  276. package/src/components/upload/upload.meta.md +49 -4
  277. package/src/components/upload/upload.stories.tsx +72 -12
  278. package/src/components/upload/upload.tsx +170 -37
  279. package/src/components/watermark/watermark.meta.md +7 -2
  280. package/src/components/watermark/watermark.stories.tsx +101 -9
  281. package/src/components/watermark/watermark.tsx +1 -0
  282. package/src/hooks/use-breakpoint.ts +117 -0
  283. package/src/hooks/use-debounce-callback.ts +52 -0
  284. package/src/hooks/use-mobile.ts +23 -0
  285. package/src/stories/theme-tokens.stories.tsx +747 -0
  286. package/src/utils/trigger-input.ts +53 -0
  287. package/src/components/button-group/button-group.meta.md +0 -92
  288. package/src/components/button-group/button-group.stories.tsx +0 -90
  289. package/src/components/button-group/button-group.tsx +0 -75
  290. package/src/components/combobox/combobox.meta.md +0 -93
  291. package/src/components/combobox/combobox.stories.tsx +0 -55
  292. package/src/components/combobox/combobox.tsx +0 -130
  293. package/src/components/space/space.meta.md +0 -94
  294. package/src/components/space/space.stories.tsx +0 -94
  295. package/src/components/space/space.tsx +0 -106
@@ -1,94 +1,859 @@
1
1
  import * as React from 'react';
2
- import { format } from 'date-fns';
3
- import { Calendar as CalendarIcon } from 'lucide-react';
4
- import type { DateRange } from 'react-day-picker';
2
+ import {
3
+ endOfMonth,
4
+ endOfWeek,
5
+ format as formatDate,
6
+ startOfDay,
7
+ startOfMonth,
8
+ startOfWeek,
9
+ subDays,
10
+ subMonths,
11
+ } from 'date-fns';
12
+ import { zhCN } from 'date-fns/locale';
13
+ import {
14
+ Calendar as CalendarIcon,
15
+ ChevronLeft,
16
+ ChevronRight,
17
+ ChevronsLeft,
18
+ ChevronsRight,
19
+ X,
20
+ } from 'lucide-react';
21
+ import {
22
+ useDayPicker,
23
+ type DateRange,
24
+ type DayPickerProps,
25
+ } from 'react-day-picker';
5
26
 
6
27
  import { cn } from '@/utils/cn';
7
- import { Button } from '@/components/button/button';
28
+ import {
29
+ datesEqual,
30
+ inputElementClass,
31
+ triggerSizeClass,
32
+ triggerWrapperClass,
33
+ tryParseDate,
34
+ } from '@/utils/trigger-input';
35
+ import { Button, buttonVariants } from '@/components/button/button';
8
36
  import { Calendar } from '@/components/calendar/calendar';
9
37
  import {
10
38
  Popover,
39
+ PopoverAnchor,
11
40
  PopoverContent,
12
- PopoverTrigger,
13
41
  } from '@/components/popover/popover';
42
+ import {
43
+ TimePickerPanel,
44
+ parseTime,
45
+ formatTime,
46
+ type TimeParts,
47
+ } from '@/components/time-picker/time-picker';
48
+
49
+ // ─── showTime 配置类型 ───────────────────────────────────────────────────
50
+
51
+ export interface ShowTimeConfig {
52
+ /** 时间格式(默认 "HH:mm:ss")。 */
53
+ format?: string;
54
+ /** 默认时间(未选择时显示的时间值,格式同 `format`)。 */
55
+ defaultValue?: string;
56
+ /** 小时步长。 */
57
+ hourStep?: number;
58
+ /** 分钟步长。 */
59
+ minuteStep?: number;
60
+ /** 秒步长。 */
61
+ secondStep?: number;
62
+ /** 禁用小时。 */
63
+ disabledHours?: () => number[];
64
+ /** 禁用分钟。 */
65
+ disabledMinutes?: (hour: number) => number[];
66
+ /** 禁用秒。 */
67
+ disabledSeconds?: (hour: number, minute: number) => number[];
68
+ }
69
+
70
+ const DEFAULT_TIME_FORMAT = 'HH:mm:ss';
71
+
72
+ // ─── Preset 类型 ────────────────────────────────────────────────────────
73
+
74
+ /** DatePicker(单日)preset 项 —— `value` 可以是 Date 或返回 Date 的函数。 */
75
+ export interface DatePickerPreset {
76
+ label: string;
77
+ value: Date | (() => Date);
78
+ }
79
+
80
+ /** DateRangePicker(范围)preset 项 —— `value` 可以是 DateRange 或返回 DateRange 的函数。 */
81
+ export interface DateRangePreset {
82
+ label: string;
83
+ value: DateRange | (() => DateRange);
84
+ }
85
+
86
+ /** 内置单日 presets:今天 / 昨天。 */
87
+ const DEFAULT_DATE_PRESETS: DatePickerPreset[] = [
88
+ { label: '今天', value: () => new Date() },
89
+ { label: '昨天', value: () => subDays(new Date(), 1) },
90
+ ];
91
+
92
+ /** 内置范围 presets:今天 / 昨天 / 本周 / 上周 / 本月 / 上月。 */
93
+ const DEFAULT_RANGE_PRESETS: DateRangePreset[] = [
94
+ { label: '今天', value: () => ({ from: new Date(), to: new Date() }) },
95
+ {
96
+ label: '昨天',
97
+ value: () => {
98
+ const y = subDays(new Date(), 1);
99
+ return { from: y, to: y };
100
+ },
101
+ },
102
+ {
103
+ label: '本周',
104
+ value: () => ({
105
+ from: startOfWeek(new Date(), { weekStartsOn: 1 }),
106
+ to: endOfWeek(new Date(), { weekStartsOn: 1 }),
107
+ }),
108
+ },
109
+ {
110
+ label: '上周',
111
+ value: () => {
112
+ const lastWeek = subDays(new Date(), 7);
113
+ return {
114
+ from: startOfWeek(lastWeek, { weekStartsOn: 1 }),
115
+ to: endOfWeek(lastWeek, { weekStartsOn: 1 }),
116
+ };
117
+ },
118
+ },
119
+ {
120
+ label: '本月',
121
+ value: () => ({
122
+ from: startOfMonth(new Date()),
123
+ to: endOfMonth(new Date()),
124
+ }),
125
+ },
126
+ {
127
+ label: '上月',
128
+ value: () => {
129
+ const lastMonth = subMonths(new Date(), 1);
130
+ return {
131
+ from: startOfMonth(lastMonth),
132
+ to: endOfMonth(lastMonth),
133
+ };
134
+ },
135
+ },
136
+ ];
137
+
138
+ const resolveDatePresets = (
139
+ presets: boolean | DatePickerPreset[] | undefined,
140
+ ): DatePickerPreset[] => {
141
+ if (!presets) return [];
142
+ if (presets === true) return DEFAULT_DATE_PRESETS;
143
+ return presets;
144
+ };
145
+
146
+ const resolveRangePresets = (
147
+ presets: boolean | DateRangePreset[] | undefined,
148
+ ): DateRangePreset[] => {
149
+ if (!presets) return [];
150
+ if (presets === true) return DEFAULT_RANGE_PRESETS;
151
+ return presets;
152
+ };
153
+
154
+ const resolvePresetValue = <T,>(v: T | (() => T)): T =>
155
+ typeof v === 'function' ? (v as () => T)() : v;
156
+
157
+ /**
158
+ * Preview range 虚线视觉 —— 中性灰 dashed border。
159
+ *
160
+ * 实现关键:day cell(td)默认带 1px transparent dashed border 占位(见 calendar.tsx day slot),
161
+ * preview 时仅切换 border-color 不改尺寸 → 避免布局跳变。配合 month_grid 的 `border-collapse`,
162
+ * 相邻 cell border 自然合并为连续虚线。
163
+ *
164
+ * 三段 modifier:
165
+ * - `previewRange`:行首 / 行末(`first` / `last`)颜色给到 left / right border + 圆角
166
+ * - `previewStart` / `previewEnd`:range 端点强制 left / right 边 + 圆角(端点不在行首/末时也闭合)
167
+ * - selected 端点(`[data-selected]`)border 还原透明,避免与蓝色填充叠加虚线
168
+ */
169
+ const PREVIEW_RANGE_CLASS =
170
+ '!border-y-foreground/40 first:!border-l-foreground/40 first:rounded-l-md last:!border-r-foreground/40 last:rounded-r-md [&[data-selected]]:!border-transparent';
171
+ const PREVIEW_START_CLASS = '!border-l-foreground/40 rounded-l-md';
172
+ const PREVIEW_END_CLASS = '!border-r-foreground/40 rounded-r-md';
173
+
174
+ // ─── 日历面板本地化(DatePicker 内部覆盖,不影响 Calendar 直接使用者) ───
175
+
176
+ const navButtonClass = cn(
177
+ buttonVariants({ variant: 'ghost', size: 'icon' }),
178
+ 'size-7 bg-transparent opacity-60 hover:opacity-100 disabled:pointer-events-none disabled:opacity-30',
179
+ );
180
+
181
+ /**
182
+ * 自定义 Nav:左右各两枚按钮(年/月双向导航)。
183
+ * 透传 react-day-picker 的 nav className(absolute 定位)。
184
+ */
185
+ function DatePickerNav({
186
+ className,
187
+ }: React.HTMLAttributes<HTMLElement> & {
188
+ onPreviousClick?: React.MouseEventHandler<HTMLButtonElement>;
189
+ onNextClick?: React.MouseEventHandler<HTMLButtonElement>;
190
+ previousMonth?: Date;
191
+ nextMonth?: Date;
192
+ }): React.JSX.Element {
193
+ const { goToMonth, nextMonth, previousMonth, months } = useDayPicker();
194
+ const currentMonth = months[0]?.date;
195
+ const prevYear = currentMonth
196
+ ? new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1)
197
+ : undefined;
198
+ const nextYear = currentMonth
199
+ ? new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1)
200
+ : undefined;
201
+
202
+ return (
203
+ <nav className={className}>
204
+ <div className="flex items-center gap-0.5">
205
+ <button
206
+ type="button"
207
+ aria-label="上一年"
208
+ className={navButtonClass}
209
+ disabled={!prevYear}
210
+ onClick={() => prevYear && goToMonth(prevYear)}
211
+ >
212
+ <ChevronsLeft className="size-4" />
213
+ </button>
214
+ <button
215
+ type="button"
216
+ aria-label="上个月"
217
+ className={navButtonClass}
218
+ disabled={!previousMonth}
219
+ onClick={() => previousMonth && goToMonth(previousMonth)}
220
+ >
221
+ <ChevronLeft className="size-4" />
222
+ </button>
223
+ </div>
224
+ <div className="flex items-center gap-0.5">
225
+ <button
226
+ type="button"
227
+ aria-label="下个月"
228
+ className={navButtonClass}
229
+ disabled={!nextMonth}
230
+ onClick={() => nextMonth && goToMonth(nextMonth)}
231
+ >
232
+ <ChevronRight className="size-4" />
233
+ </button>
234
+ <button
235
+ type="button"
236
+ aria-label="下一年"
237
+ className={navButtonClass}
238
+ disabled={!nextYear}
239
+ onClick={() => nextYear && goToMonth(nextYear)}
240
+ >
241
+ <ChevronsRight className="size-4" />
242
+ </button>
243
+ </div>
244
+ </nav>
245
+ );
246
+ }
247
+
248
+ /** 中文化 + 视觉对齐 cloud-design DatePicker2 的 Calendar 透传 props。 */
249
+ const datePickerCalendarProps = {
250
+ locale: zhCN,
251
+ weekStartsOn: 1,
252
+ formatters: {
253
+ formatCaption: (month: Date) =>
254
+ `${month.getFullYear()}年 ${month.getMonth() + 1}月`,
255
+ },
256
+ classNames: {
257
+ // 今日仅蓝色文字,不填充背景(选中态保留 bg-primary)
258
+ today: 'text-primary font-medium',
259
+ },
260
+ components: {
261
+ Nav: DatePickerNav,
262
+ },
263
+ } satisfies Pick<
264
+ DayPickerProps,
265
+ 'locale' | 'weekStartsOn' | 'formatters' | 'classNames' | 'components'
266
+ >;
267
+
268
+ // ─── MonthPanel / YearPanel(picker='month'|'year' 时替代 Calendar 日期网格) ─────
269
+
270
+ const MONTH_LABELS = [
271
+ '1月',
272
+ '2月',
273
+ '3月',
274
+ '4月',
275
+ '5月',
276
+ '6月',
277
+ '7月',
278
+ '8月',
279
+ '9月',
280
+ '10月',
281
+ '11月',
282
+ '12月',
283
+ ] as const;
284
+
285
+ function MonthPanel({
286
+ value,
287
+ onSelect,
288
+ disabledDates,
289
+ }: {
290
+ value?: Date;
291
+ onSelect: (d: Date) => void;
292
+ disabledDates?: (d: Date) => boolean;
293
+ }) {
294
+ const [year, setYear] = React.useState(
295
+ () => value?.getFullYear() ?? new Date().getFullYear(),
296
+ );
297
+ const selectedMonth =
298
+ value && value.getFullYear() === year ? value.getMonth() : -1;
299
+
300
+ return (
301
+ <div className="flex flex-col gap-3 p-3">
302
+ <div className="flex items-center justify-between">
303
+ <button
304
+ type="button"
305
+ className={navButtonClass}
306
+ onClick={() => setYear(year - 1)}
307
+ >
308
+ <ChevronLeft className="size-4" />
309
+ </button>
310
+ <span className="text-xs font-medium">{year}年</span>
311
+ <button
312
+ type="button"
313
+ className={navButtonClass}
314
+ onClick={() => setYear(year + 1)}
315
+ >
316
+ <ChevronRight className="size-4" />
317
+ </button>
318
+ </div>
319
+ <div className="grid grid-cols-3 gap-2">
320
+ {MONTH_LABELS.map((label, i) => {
321
+ const d = new Date(year, i, 1);
322
+ const isDisabled = disabledDates?.(d) ?? false;
323
+ return (
324
+ <button
325
+ key={i}
326
+ type="button"
327
+ disabled={isDisabled}
328
+ onClick={() => onSelect(d)}
329
+ className={cn(
330
+ 'h-8 cursor-pointer rounded-md text-xs transition-colors',
331
+ 'hover:bg-accent hover:text-accent-foreground',
332
+ 'disabled:pointer-events-none disabled:opacity-50',
333
+ selectedMonth === i &&
334
+ 'bg-primary text-primary-foreground hover:bg-primary/90',
335
+ )}
336
+ >
337
+ {label}
338
+ </button>
339
+ );
340
+ })}
341
+ </div>
342
+ </div>
343
+ );
344
+ }
345
+
346
+ function YearPanel({
347
+ value,
348
+ onSelect,
349
+ disabledDates,
350
+ }: {
351
+ value?: Date;
352
+ onSelect: (d: Date) => void;
353
+ disabledDates?: (d: Date) => boolean;
354
+ }) {
355
+ const baseYear = value?.getFullYear() ?? new Date().getFullYear();
356
+ const [decadeStart, setDecadeStart] = React.useState(
357
+ () => Math.floor(baseYear / 10) * 10,
358
+ );
359
+ const selectedYear = value?.getFullYear() ?? -1;
360
+ const years = Array.from({ length: 12 }, (_, i) => decadeStart - 1 + i);
361
+
362
+ return (
363
+ <div className="flex flex-col gap-3 p-3">
364
+ <div className="flex items-center justify-between">
365
+ <button
366
+ type="button"
367
+ className={navButtonClass}
368
+ onClick={() => setDecadeStart(decadeStart - 10)}
369
+ >
370
+ <ChevronLeft className="size-4" />
371
+ </button>
372
+ <span className="text-xs font-medium">
373
+ {decadeStart} – {decadeStart + 9}
374
+ </span>
375
+ <button
376
+ type="button"
377
+ className={navButtonClass}
378
+ onClick={() => setDecadeStart(decadeStart + 10)}
379
+ >
380
+ <ChevronRight className="size-4" />
381
+ </button>
382
+ </div>
383
+ <div className="grid grid-cols-3 gap-2">
384
+ {years.map((y) => {
385
+ const d = new Date(y, 0, 1);
386
+ const isDisabled = disabledDates?.(d) ?? false;
387
+ const isOutside = y < decadeStart || y > decadeStart + 9;
388
+ return (
389
+ <button
390
+ key={y}
391
+ type="button"
392
+ disabled={isDisabled}
393
+ onClick={() => onSelect(d)}
394
+ className={cn(
395
+ 'h-8 cursor-pointer rounded-md text-xs transition-colors',
396
+ 'hover:bg-accent hover:text-accent-foreground',
397
+ 'disabled:pointer-events-none disabled:opacity-50',
398
+ isOutside && 'text-muted-foreground/60',
399
+ selectedYear === y &&
400
+ 'bg-primary text-primary-foreground hover:bg-primary/90',
401
+ )}
402
+ >
403
+ {y}
404
+ </button>
405
+ );
406
+ })}
407
+ </div>
408
+ </div>
409
+ );
410
+ }
411
+
412
+ const dateToParts = (d: Date | undefined): TimeParts =>
413
+ d
414
+ ? { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds() }
415
+ : { h: 0, m: 0, s: 0 };
416
+
417
+ const mergeDateAndTime = (date: Date, parts: TimeParts): Date => {
418
+ const next = new Date(date);
419
+ next.setHours(parts.h, parts.m, parts.s, 0);
420
+ return next;
421
+ };
422
+
423
+ const resolveShowTime = (
424
+ showTime: boolean | ShowTimeConfig | undefined,
425
+ ): ShowTimeConfig | null => {
426
+ if (!showTime) return null;
427
+ if (showTime === true) return { format: DEFAULT_TIME_FORMAT };
428
+ return { format: DEFAULT_TIME_FORMAT, ...showTime };
429
+ };
430
+
431
+ // ─── DatePicker(单日,可选 showTime) ────────────────────────────────────
14
432
 
15
433
  export interface DatePickerProps {
16
- /** 受控值 — 当前选中日期。 */
434
+ /**
435
+ * 选择粒度(antd `picker` 并集) — 决定弹出面板类型:
436
+ * - `'date'`(默认) — 日历面板(选择具体日期)
437
+ * - `'month'` — 月份网格(选择到月)
438
+ * - `'year'` — 年份网格(选择到年)
439
+ * @default "date"
440
+ */
441
+ picker?: 'date' | 'month' | 'year';
442
+ /** 受控值 — 当前选中日期(`showTime` 时含时间)。 */
17
443
  value?: Date;
18
- /** 选中日期变化回调。 */
444
+ /**
445
+ * 非受控初始值(对齐 antd / cd `defaultValue`)。仅在未传 `value` 时生效。
446
+ */
447
+ defaultValue?: Date;
448
+ /** 选中变化回调。`showTime` 时点击「确定」才触发,否则点击日期立即触发。 */
19
449
  onChange?: (value: Date | undefined) => void;
20
- /** 占位文本(未选时)。 @default "选择日期" */
450
+ /** 占位文本。 @default "选择日期" */
21
451
  placeholder?: string;
22
- /** date-fns format 字符串。 @default "yyyy-MM-dd" */
452
+ /**
453
+ * 显示格式(date-fns 语法)。`showTime` 时建议含时间 token,如 `yyyy-MM-dd HH:mm:ss`。
454
+ * @default "yyyy-MM-dd"
455
+ */
23
456
  format?: string;
24
- /** 触发器尺寸。 */
25
- size?: 'sm' | 'default' | 'lg';
26
- /** 触发器 className(传给底层 Button)。 */
457
+ /** 触发器尺寸。 @default "md" */
458
+ size?: 'sm' | 'md' | 'default' | 'lg';
459
+ /** 触发器 className。 */
27
460
  className?: string;
28
- /** 触发器是否禁用。 */
461
+ /** 是否禁用。 */
29
462
  disabled?: boolean;
30
- /** 不可选日期 predicate(透传到 Calendar `disabled`)。 */
463
+ /** 不可选日期 predicate。 */
31
464
  disabledDates?: (date: Date) => boolean;
465
+ /**
466
+ * 显示时间选择器(antd `showTime` 并集)。
467
+ * - `true` — 启用,使用默认 `HH:mm:ss` 格式
468
+ * - `ShowTimeConfig` — 详细配置(format / 默认值 / 步长 / 禁用回调)
469
+ * - `false` / 缺省 — 仅日期(默认行为,向后兼容)
470
+ * @default false
471
+ */
472
+ showTime?: boolean | ShowTimeConfig;
473
+ /**
474
+ * 弹层底部快捷 preset 链接(对齐 antd / cloud-design DatePicker2 的 `presets` 能力)。
475
+ * - `true` — 启用内置默认集(今天 / 昨天)
476
+ * - `DatePickerPreset[]` — 自定义集
477
+ * - `false` / 缺省 — 不显示
478
+ * @default false
479
+ */
480
+ presets?: boolean | DatePickerPreset[];
481
+ /**
482
+ * 显示清除按钮(antd `allowClear` 并集);有值时显示 X 替代日历图标,点击清空 value。
483
+ * @default true
484
+ */
485
+ hasClear?: boolean;
486
+ /**
487
+ * 校验状态(对齐 antd `status`) — 控制触发器边框 / 字体颜色:
488
+ * - `'error'` — 红色 destructive 边框 + `aria-invalid="true"`
489
+ * - `'warning'` — 警告色边框
490
+ * - `'default'` / 缺省 — 中性边框
491
+ * @default "default"
492
+ */
493
+ status?: 'default' | 'error' | 'warning';
494
+ /**
495
+ * 受控弹层显隐(对齐 antd `open`)。
496
+ * 业务侧需要"程序化打开 / 关闭"时使用;不传则组件内部自管。
497
+ */
498
+ open?: boolean;
499
+ /** 弹层显隐变化回调(对齐 antd `onOpenChange`)。 */
500
+ onOpenChange?: (open: boolean) => void;
501
+ /**
502
+ * 弹层底部自定义 footer(对齐 antd `footer / renderExtraFooter`)。
503
+ * 与 `presets` 共存时,footer 渲染在 presets 之下。
504
+ */
505
+ footer?: React.ReactNode;
506
+ /**
507
+ * 输入框只读 — 仅能通过点击弹层选择,不能直接键入(对齐 antd `inputReadOnly`)。
508
+ * @default false
509
+ */
510
+ inputReadOnly?: boolean;
32
511
  }
33
512
 
34
- const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
513
+ const triggerWidth = (showTime: ShowTimeConfig | null) =>
514
+ showTime ? 'w-64' : 'w-60';
515
+
516
+ // trigger 样式 / parse 工具迁移至 `@/utils/trigger-input`,4 个 picker 共享。
517
+
518
+ const DatePicker = React.forwardRef<HTMLInputElement, DatePickerProps>(
35
519
  (
36
520
  {
37
- value,
521
+ value: valueProp,
522
+ defaultValue,
38
523
  onChange,
39
- placeholder = '选择日期',
40
- format: fmt = 'yyyy-MM-dd',
41
- size = 'default',
524
+ picker = 'date',
525
+ placeholder: placeholderProp,
526
+ format: fmt,
527
+ size = 'md',
42
528
  className,
43
529
  disabled,
44
530
  disabledDates,
531
+ showTime,
532
+ presets,
533
+ hasClear = true,
534
+ status = 'default',
535
+ open: openProp,
536
+ onOpenChange,
537
+ footer,
538
+ inputReadOnly = false,
45
539
  },
46
540
  ref,
47
- ) => (
48
- <Popover>
49
- <PopoverTrigger asChild>
50
- <Button
51
- ref={ref}
52
- variant="outline"
53
- size={size}
54
- disabled={disabled}
55
- className={cn(
56
- 'w-60 justify-start text-left font-normal',
57
- !value && 'text-muted-foreground',
58
- className,
59
- )}
60
- icon={<CalendarIcon />}
541
+ ) => {
542
+ const timeCfg = picker === 'date' ? resolveShowTime(showTime) : null;
543
+ const displayFormat =
544
+ fmt ??
545
+ (picker === 'year'
546
+ ? 'yyyy'
547
+ : picker === 'month'
548
+ ? 'yyyy-MM'
549
+ : timeCfg
550
+ ? 'yyyy-MM-dd HH:mm:ss'
551
+ : 'yyyy-MM-dd');
552
+ const placeholder =
553
+ placeholderProp ??
554
+ (picker === 'year'
555
+ ? '选择年份'
556
+ : picker === 'month'
557
+ ? '选择月份'
558
+ : '选择日期');
559
+ const presetList = resolveDatePresets(presets);
560
+
561
+ // value 受控 / 非受控 — 优先使用 valueProp,缺省时回退到内部 state(初值 defaultValue)
562
+ const isValueControlled = valueProp !== undefined;
563
+ const [internalValue, setInternalValue] = React.useState<Date | undefined>(
564
+ defaultValue,
565
+ );
566
+ const value = isValueControlled ? valueProp : internalValue;
567
+
568
+ // open 受控 / 非受控 — 优先 openProp,缺省时内部自管
569
+ const isOpenControlled = openProp !== undefined;
570
+ const [openInternal, setOpenInternal] = React.useState(false);
571
+ const open = isOpenControlled ? openProp : openInternal;
572
+ const setOpen = (next: boolean) => {
573
+ if (!isOpenControlled) setOpenInternal(next);
574
+ onOpenChange?.(next);
575
+ };
576
+
577
+ const [draftDate, setDraftDate] = React.useState<Date | undefined>(value);
578
+ const [draftTime, setDraftTime] = React.useState<TimeParts>(() =>
579
+ dateToParts(value),
580
+ );
581
+
582
+ // ─── input 状态 ──────────────────────────────────────────────
583
+ // inputText 是用户可编辑的文本草稿,与 value 双向同步:
584
+ // - value 变化(外部 / 选择 / preset)→ inputText 同步(仅非 focus 时)
585
+ // - input blur → parse inputText → 合法更新 value;非法清空
586
+ const inputRef = React.useRef<HTMLInputElement>(null);
587
+ const anchorRef = React.useRef<HTMLDivElement>(null);
588
+ React.useImperativeHandle(ref, () => inputRef.current!);
589
+ const [inputText, setInputText] = React.useState<string>(() =>
590
+ value ? formatDate(value, displayFormat) : '',
591
+ );
592
+ React.useEffect(() => {
593
+ if (document.activeElement !== inputRef.current) {
594
+ setInputText(value ? formatDate(value, displayFormat) : '');
595
+ }
596
+ }, [value, displayFormat]);
597
+
598
+ // 同步外部 value → draft(showTime 模式才用,纯日期模式直接走 value)
599
+ React.useEffect(() => {
600
+ if (open) {
601
+ setDraftDate(value);
602
+ if (value) {
603
+ setDraftTime(dateToParts(value));
604
+ } else if (timeCfg?.defaultValue) {
605
+ const parsed = parseTime(timeCfg.defaultValue);
606
+ if (parsed) setDraftTime(parsed);
607
+ }
608
+ }
609
+ // 仅在 popover 开关瞬间同步 draft,故意只依赖 open。
610
+ }, [open]);
611
+
612
+ const commitValue = (next: Date | undefined) => {
613
+ // 立即同步 input 文本 —— 不能只靠 useEffect,因为选择时 input 仍 focused,
614
+ // useEffect 的 `activeElement !== input` 条件会跳过同步。
615
+ setInputText(next ? formatDate(next, displayFormat) : '');
616
+ if (datesEqual(value, next)) return;
617
+ if (!isValueControlled) setInternalValue(next);
618
+ onChange?.(next);
619
+ };
620
+
621
+ const handleInputBlur = () => {
622
+ const parsed = tryParseDate(inputText, displayFormat);
623
+ if (parsed) {
624
+ commitValue(parsed);
625
+ } else if (inputText.trim() === '') {
626
+ commitValue(undefined);
627
+ } else {
628
+ // 非法 → 清空
629
+ setInputText('');
630
+ commitValue(undefined);
631
+ }
632
+ };
633
+
634
+ const handleSelectDate = (d: Date | undefined) => {
635
+ if (!timeCfg) {
636
+ commitValue(d);
637
+ setOpen(false);
638
+ return;
639
+ }
640
+ setDraftDate(d);
641
+ // showTime:实时同步 input text(merge date + draftTime)
642
+ if (d) {
643
+ setInputText(formatDate(mergeDateAndTime(d, draftTime), displayFormat));
644
+ }
645
+ };
646
+
647
+ const handleSelectTime = (next: TimeParts) => {
648
+ setDraftTime(next);
649
+ // showTime:实时同步 input text(merge draftDate + new time)
650
+ if (draftDate) {
651
+ setInputText(
652
+ formatDate(mergeDateAndTime(draftDate, next), displayFormat),
653
+ );
654
+ }
655
+ };
656
+
657
+ const handleConfirm = () => {
658
+ if (!draftDate) {
659
+ setOpen(false);
660
+ return;
661
+ }
662
+ commitValue(mergeDateAndTime(draftDate, draftTime));
663
+ setOpen(false);
664
+ };
665
+
666
+ const handlePresetClick = (preset: DatePickerPreset) => {
667
+ const d = resolvePresetValue(preset.value);
668
+ if (!timeCfg) {
669
+ commitValue(d);
670
+ setOpen(false);
671
+ return;
672
+ }
673
+ setDraftDate(d);
674
+ setDraftTime(dateToParts(d));
675
+ setInputText(formatDate(d, displayFormat));
676
+ };
677
+
678
+ const handleClear = (e: React.MouseEvent) => {
679
+ e.preventDefault(); // 不触发 input blur
680
+ commitValue(undefined);
681
+ inputRef.current?.focus();
682
+ };
683
+
684
+ const hasValue = value != null;
685
+ // hasClear:有值显示 X(可清除),空值还原为日历 icon。默认 true,可传 hasClear={false} 关闭。
686
+ const showClear = hasClear && hasValue && !disabled;
687
+
688
+ return (
689
+ <Popover open={open} onOpenChange={setOpen}>
690
+ <PopoverAnchor asChild>
691
+ <div
692
+ ref={anchorRef}
693
+ aria-invalid={status === 'error' ? true : undefined}
694
+ className={cn(
695
+ triggerWrapperClass,
696
+ triggerSizeClass[size],
697
+ triggerWidth(timeCfg),
698
+ status === 'error' &&
699
+ 'border-destructive aria-invalid:border-destructive',
700
+ status === 'warning' && 'border-warning',
701
+ className,
702
+ )}
703
+ >
704
+ <input
705
+ ref={inputRef}
706
+ type="text"
707
+ value={inputText}
708
+ onChange={(e) => setInputText(e.target.value)}
709
+ onFocus={() => setOpen(true)}
710
+ onBlur={handleInputBlur}
711
+ placeholder={placeholder}
712
+ disabled={disabled}
713
+ readOnly={inputReadOnly}
714
+ className={inputElementClass}
715
+ />
716
+ {showClear ? (
717
+ <button
718
+ type="button"
719
+ aria-label="清除日期"
720
+ tabIndex={-1}
721
+ onMouseDown={handleClear}
722
+ className="flex shrink-0 cursor-pointer items-center text-muted-foreground transition-colors hover:text-foreground"
723
+ >
724
+ <X className="size-4" />
725
+ </button>
726
+ ) : (
727
+ <CalendarIcon
728
+ aria-hidden
729
+ className="size-4 shrink-0 text-muted-foreground"
730
+ />
731
+ )}
732
+ </div>
733
+ </PopoverAnchor>
734
+ <PopoverContent
735
+ className="w-auto rounded-lg p-0"
736
+ onOpenAutoFocus={(e) => e.preventDefault()}
737
+ onInteractOutside={(e) => {
738
+ // 点击 / focus 落在 anchor(trigger wrapper)内时,popover 不关闭。
739
+ // anchor 是我们自管的 trigger,Radix 默认仅认 PopoverTrigger,
740
+ // 不加这层判断会在 input focus 触发 open 后立即被 outside 关闭。
741
+ const target = e.detail.originalEvent.target as Node | null;
742
+ if (target && anchorRef.current?.contains(target)) {
743
+ e.preventDefault();
744
+ }
745
+ }}
61
746
  >
62
- {value ? format(value, fmt) : placeholder}
63
- </Button>
64
- </PopoverTrigger>
65
- <PopoverContent className="w-auto p-0">
66
- <Calendar
67
- mode="single"
68
- selected={value}
69
- onSelect={onChange}
70
- disabled={disabledDates}
71
- initialFocus
72
- />
73
- </PopoverContent>
74
- </Popover>
75
- ),
747
+ {timeCfg ? (
748
+ <>
749
+ <div className="flex items-stretch">
750
+ <Calendar
751
+ mode="single"
752
+ selected={draftDate}
753
+ onSelect={handleSelectDate}
754
+ disabled={disabledDates}
755
+ defaultMonth={value}
756
+ {...datePickerCalendarProps}
757
+ />
758
+ <TimePickerPanel
759
+ value={draftTime}
760
+ onChange={handleSelectTime}
761
+ format={timeCfg.format ?? DEFAULT_TIME_FORMAT}
762
+ hourStep={timeCfg.hourStep}
763
+ minuteStep={timeCfg.minuteStep}
764
+ secondStep={timeCfg.secondStep}
765
+ disabledHours={timeCfg.disabledHours}
766
+ disabledMinutes={timeCfg.disabledMinutes}
767
+ disabledSeconds={timeCfg.disabledSeconds}
768
+ hasSelection={draftDate != null}
769
+ padZero={false}
770
+ className="border-l border-border"
771
+ />
772
+ </div>
773
+ <div className="flex items-center justify-between border-t border-border px-3 py-2">
774
+ <div className="flex flex-wrap items-center gap-3">
775
+ {presetList.map((p) => (
776
+ <button
777
+ key={p.label}
778
+ type="button"
779
+ className="cursor-pointer text-xs text-primary transition-colors hover:text-primary/80"
780
+ onClick={() => handlePresetClick(p)}
781
+ >
782
+ {p.label}
783
+ </button>
784
+ ))}
785
+ </div>
786
+ <Button size="sm" onClick={handleConfirm} disabled={!draftDate}>
787
+ 确定
788
+ </Button>
789
+ </div>
790
+ </>
791
+ ) : (
792
+ <>
793
+ {picker === 'month' ? (
794
+ <MonthPanel
795
+ value={value}
796
+ onSelect={handleSelectDate}
797
+ disabledDates={disabledDates}
798
+ />
799
+ ) : picker === 'year' ? (
800
+ <YearPanel
801
+ value={value}
802
+ onSelect={handleSelectDate}
803
+ disabledDates={disabledDates}
804
+ />
805
+ ) : (
806
+ <Calendar
807
+ mode="single"
808
+ selected={value}
809
+ onSelect={handleSelectDate}
810
+ disabled={disabledDates}
811
+ defaultMonth={value}
812
+ {...datePickerCalendarProps}
813
+ />
814
+ )}
815
+ {presetList.length > 0 && (
816
+ <div className="flex flex-wrap items-center gap-3 border-t border-border px-3 py-2">
817
+ {presetList.map((p) => (
818
+ <button
819
+ key={p.label}
820
+ type="button"
821
+ className="cursor-pointer text-xs text-primary transition-colors hover:text-primary/80"
822
+ onClick={() => handlePresetClick(p)}
823
+ >
824
+ {p.label}
825
+ </button>
826
+ ))}
827
+ </div>
828
+ )}
829
+ </>
830
+ )}
831
+ {footer ? (
832
+ <div className="border-t border-border p-2 text-xs">{footer}</div>
833
+ ) : null}
834
+ </PopoverContent>
835
+ </Popover>
836
+ );
837
+ },
76
838
  );
77
839
  DatePicker.displayName = 'DatePicker';
78
840
 
79
- // ─── DateRangePicker(antd RangePicker 并集)───────────────────────────────
841
+ // ─── DateRangePicker(范围,可选 showTime) ──────────────────────────────
80
842
 
81
843
  export interface DateRangePickerProps {
82
844
  /** 受控值 — 当前选中范围。 */
83
845
  value?: DateRange;
846
+ /** 非受控初始值(对齐 antd / cd `defaultValue`)。仅在未传 `value` 时生效。 */
847
+ defaultValue?: DateRange;
84
848
  /** 范围变化回调。 */
85
849
  onChange?: (value: DateRange | undefined) => void;
86
- /** 占位文本。 @default "选择日期范围" */
87
- placeholder?: string;
88
- /** date-fns format 字符串。 @default "yyyy-MM-dd" */
850
+ /**
851
+ * 显示格式(date-fns 语法)。
852
+ * @default "yyyy-MM-dd"
853
+ */
89
854
  format?: string;
90
- /** 触发器尺寸。 */
91
- size?: 'sm' | 'default' | 'lg';
855
+ /** 触发器尺寸。 @default "md" */
856
+ size?: 'sm' | 'md' | 'default' | 'lg';
92
857
  /** 触发器 className。 */
93
858
  className?: string;
94
859
  /** 是否禁用。 */
@@ -97,59 +862,692 @@ export interface DateRangePickerProps {
97
862
  disabledDates?: (date: Date) => boolean;
98
863
  /** 同时显示几个月历。 @default 2 */
99
864
  numberOfMonths?: number;
865
+ /**
866
+ * 起始 / 结束占位文本(`showTime` 时使用双段触发器)。
867
+ * @default ["起始日期","结束日期"]
868
+ */
869
+ placeholders?: [string, string];
870
+ /**
871
+ * 显示时间选择器(antd `showTime` 并集)。
872
+ * @default false
873
+ */
874
+ showTime?: boolean | ShowTimeConfig;
875
+ /**
876
+ * 弹层底部快捷 preset 链接(对齐 antd / cloud-design DatePicker2 的 `presets` 能力)。
877
+ * - `true` — 启用内置默认集(今天 / 昨天 / 本周 / 上周 / 本月 / 上月)
878
+ * - `DateRangePreset[]` — 自定义集
879
+ * - `false` / 缺省 — 不显示
880
+ * @default false
881
+ */
882
+ presets?: boolean | DateRangePreset[];
883
+ /**
884
+ * 显示清除按钮(antd `allowClear` 并集)。有值时显示 X,空值还原为日历 icon。
885
+ * @default true
886
+ */
887
+ hasClear?: boolean;
888
+ /**
889
+ * 校验状态(对齐 antd `status`)— `'error'` / `'warning'` / `'default'`。
890
+ * @default "default"
891
+ */
892
+ status?: 'default' | 'error' | 'warning';
893
+ /** 受控弹层显隐(对齐 antd `open`)。 */
894
+ open?: boolean;
895
+ /** 弹层显隐变化回调。 */
896
+ onOpenChange?: (open: boolean) => void;
897
+ /** 弹层底部自定义 footer(渲染在 presets 下方)。 */
898
+ footer?: React.ReactNode;
899
+ /**
900
+ * 输入框只读 — 仅能通过点击弹层选择,不能直接键入。
901
+ * @default false
902
+ */
903
+ inputReadOnly?: boolean;
100
904
  }
101
905
 
102
906
  const DateRangePicker = React.forwardRef<
103
- HTMLButtonElement,
907
+ HTMLInputElement,
104
908
  DateRangePickerProps
105
909
  >(
106
910
  (
107
911
  {
108
- value,
912
+ value: valueProp,
913
+ defaultValue,
109
914
  onChange,
110
- placeholder = '选择日期范围',
111
- format: fmt = 'yyyy-MM-dd',
112
- size = 'default',
915
+ format: fmt,
916
+ size = 'md',
113
917
  className,
114
918
  disabled,
115
919
  disabledDates,
116
920
  numberOfMonths = 2,
921
+ placeholders = ['起始日期', '结束日期'],
922
+ showTime,
923
+ presets,
924
+ hasClear = true,
925
+ status = 'default',
926
+ open: openProp,
927
+ onOpenChange,
928
+ footer,
929
+ inputReadOnly = false,
117
930
  },
118
931
  ref,
119
- ) => (
120
- <Popover>
121
- <PopoverTrigger asChild>
122
- <Button
123
- ref={ref}
124
- variant="outline"
125
- size={size}
126
- disabled={disabled}
127
- className={cn(
128
- 'w-72 justify-start text-left font-normal',
129
- !value?.from && 'text-muted-foreground',
130
- className,
131
- )}
132
- icon={<CalendarIcon />}
932
+ ) => {
933
+ const timeCfg = resolveShowTime(showTime);
934
+ const displayFormat =
935
+ fmt ?? (timeCfg ? 'yyyy-MM-dd HH:mm:ss' : 'yyyy-MM-dd');
936
+ const presetList = resolveRangePresets(presets);
937
+
938
+ // value 受控 / 非受控
939
+ const isValueControlled = valueProp !== undefined;
940
+ const [internalValue, setInternalValue] = React.useState<
941
+ DateRange | undefined
942
+ >(defaultValue);
943
+ const value = isValueControlled ? valueProp : internalValue;
944
+
945
+ // open 受控 / 非受控
946
+ const isOpenControlled = openProp !== undefined;
947
+ const [openInternal, setOpenInternal] = React.useState(false);
948
+ const open = isOpenControlled ? openProp : openInternal;
949
+ const setOpen = (next: boolean) => {
950
+ if (!isOpenControlled) setOpenInternal(next);
951
+ onOpenChange?.(next);
952
+ };
953
+
954
+ // ─── draft state ──────────────────────────
955
+ const [draftRange, setDraftRange] = React.useState<DateRange | undefined>(
956
+ value,
957
+ );
958
+ const [draftStartTime, setDraftStartTime] = React.useState<TimeParts>(() =>
959
+ dateToParts(value?.from),
960
+ );
961
+ const [draftEndTime, setDraftEndTime] = React.useState<TimeParts>(() =>
962
+ dateToParts(value?.to),
963
+ );
964
+ // touched:跟踪 from / to 是否被用户主动选过(影响 time panel hasSelection 与 onChange tick)
965
+ const [startTouched, setStartTouched] = React.useState<boolean>(
966
+ value?.from != null,
967
+ );
968
+ const [endTouched, setEndTouched] = React.useState<boolean>(
969
+ value?.to != null,
970
+ );
971
+ // hover preview:from 已选 / to 未选中间态时显示虚线 preview
972
+ const [hoveredDate, setHoveredDate] = React.useState<Date | undefined>();
973
+ // 哪个 input 是 active(showTime 决定显示 from / to 单面板;纯日期决定下次点击填哪段)
974
+ const [activeField, setActiveField] = React.useState<'from' | 'to'>('from');
975
+
976
+ // ─── 双 input refs + text state ───────────────────────
977
+ const fromInputRef = React.useRef<HTMLInputElement>(null);
978
+ const toInputRef = React.useRef<HTMLInputElement>(null);
979
+ const anchorRef = React.useRef<HTMLDivElement>(null);
980
+ React.useImperativeHandle(ref, () => fromInputRef.current!);
981
+ // confirm 标志:避免 popover 关闭兜底 commit 与 confirm commit 重复
982
+ const confirmedRef = React.useRef(false);
983
+
984
+ const [fromText, setFromText] = React.useState<string>(() =>
985
+ value?.from ? formatDate(value.from, displayFormat) : '',
986
+ );
987
+ const [toText, setToText] = React.useState<string>(() =>
988
+ value?.to ? formatDate(value.to, displayFormat) : '',
989
+ );
990
+
991
+ // ─── helpers ──────────────────────────────────────
992
+ // 计算最终值:仅 touched 的段才输出,未 touched 段返回 undefined。
993
+ // 这样允许"只选 from 不选 to"的半 range 提交。
994
+ const finalize = (
995
+ range: DateRange | undefined,
996
+ startTime: TimeParts,
997
+ endTime: TimeParts,
998
+ fromTouched: boolean,
999
+ toTouched: boolean,
1000
+ ): DateRange | undefined => {
1001
+ const fromOut =
1002
+ fromTouched && range?.from
1003
+ ? timeCfg
1004
+ ? mergeDateAndTime(range.from, startTime)
1005
+ : range.from
1006
+ : undefined;
1007
+ const toOut =
1008
+ toTouched && range?.to
1009
+ ? timeCfg
1010
+ ? mergeDateAndTime(range.to, endTime)
1011
+ : range.to
1012
+ : undefined;
1013
+ if (!fromOut && !toOut) return undefined;
1014
+ return { from: fromOut, to: toOut };
1015
+ };
1016
+
1017
+ // input text 实时同步(根据 date + time + 当前 displayFormat)
1018
+ const updateFromText = (date: Date | undefined, time: TimeParts) => {
1019
+ if (!date) return setFromText('');
1020
+ const merged = timeCfg ? mergeDateAndTime(date, time) : date;
1021
+ setFromText(formatDate(merged, displayFormat));
1022
+ };
1023
+ const updateToText = (date: Date | undefined, time: TimeParts) => {
1024
+ if (!date) return setToText('');
1025
+ const merged = timeCfg ? mergeDateAndTime(date, time) : date;
1026
+ setToText(formatDate(merged, displayFormat));
1027
+ };
1028
+
1029
+ // 计算 range 是否相等
1030
+ const rangeEqual = (a: DateRange | undefined, b: DateRange | undefined) => {
1031
+ if (!a && !b) return true;
1032
+ if (!a || !b) return false;
1033
+ return datesEqual(a.from, b.from) && datesEqual(a.to, b.to);
1034
+ };
1035
+
1036
+ // 外部 value → input text(仅 input 非 focus 时同步,避免覆盖编辑)
1037
+ React.useEffect(() => {
1038
+ const active = document.activeElement;
1039
+ if (active !== fromInputRef.current) {
1040
+ setFromText(value?.from ? formatDate(value.from, displayFormat) : '');
1041
+ }
1042
+ if (active !== toInputRef.current) {
1043
+ setToText(value?.to ? formatDate(value.to, displayFormat) : '');
1044
+ }
1045
+ }, [value?.from, value?.to, displayFormat]);
1046
+
1047
+ // popover open 同步 draft / touched / 重置 hover / confirm 标志;close 兜底 commit
1048
+ React.useEffect(() => {
1049
+ if (open) {
1050
+ setDraftRange(value);
1051
+ setDraftStartTime(dateToParts(value?.from));
1052
+ setDraftEndTime(dateToParts(value?.to));
1053
+ setStartTouched(value?.from != null);
1054
+ setEndTouched(value?.to != null);
1055
+ setHoveredDate(undefined);
1056
+ confirmedRef.current = false;
1057
+ } else if (!confirmedRef.current) {
1058
+ // 关闭兜底:若 draft 与 value 不同 → commit(未 touched 段会被 finalize 丢成 undefined)
1059
+ const finalRange = finalize(
1060
+ draftRange,
1061
+ draftStartTime,
1062
+ draftEndTime,
1063
+ startTouched,
1064
+ endTouched,
1065
+ );
1066
+ if (!rangeEqual(value, finalRange)) {
1067
+ if (!isValueControlled) setInternalValue(finalRange);
1068
+ onChange?.(finalRange);
1069
+ }
1070
+ }
1071
+ // 仅在 popover 开关瞬间同步 draft / 兜底 commit,故意只依赖 open。
1072
+ }, [open]);
1073
+
1074
+ // ─── handlers ──────────────────────────────────────
1075
+ // calendar 选择 —— 用 react-day-picker onSelect(_newRange, triggerDate),自管 + activeField 决定填 from / to
1076
+ const handleSelect = (
1077
+ _newRange: DateRange | undefined,
1078
+ triggerDate: Date | undefined,
1079
+ ) => {
1080
+ if (!triggerDate) return;
1081
+ const triggerDay = startOfDay(triggerDate).getTime();
1082
+ let nextFrom = draftRange?.from;
1083
+ let nextTo = draftRange?.to;
1084
+ if (activeField === 'from') {
1085
+ nextFrom = triggerDate;
1086
+ // clamp:若新 from > 当前 to(按日比较)→ to 一并设为 from
1087
+ if (nextTo && startOfDay(nextTo).getTime() < triggerDay) {
1088
+ nextTo = triggerDate;
1089
+ }
1090
+ setStartTouched(true);
1091
+ if (nextTo !== draftRange?.to) setEndTouched(true);
1092
+ } else {
1093
+ nextTo = triggerDate;
1094
+ // clamp:若新 to < 当前 from(按日比较)→ from 一并设为 to
1095
+ if (nextFrom && startOfDay(nextFrom).getTime() > triggerDay) {
1096
+ nextFrom = triggerDate;
1097
+ }
1098
+ setEndTouched(true);
1099
+ if (nextFrom !== draftRange?.from) setStartTouched(true);
1100
+ }
1101
+ const next: DateRange = { from: nextFrom, to: nextTo };
1102
+ setDraftRange(next);
1103
+ // 实时同步 input text
1104
+ updateFromText(nextFrom, draftStartTime);
1105
+ updateToText(nextTo, draftEndTime);
1106
+ // 行为分流
1107
+ if (!timeCfg) {
1108
+ if (nextFrom && nextTo) {
1109
+ // range 完整 → 关闭(useEffect 兜底 commit)
1110
+ setOpen(false);
1111
+ } else if (activeField === 'from' && !nextTo) {
1112
+ // 选完 from,自动切到 to 等待用户继续
1113
+ setActiveField('to');
1114
+ toInputRef.current?.focus();
1115
+ } else if (activeField === 'to' && !nextFrom) {
1116
+ setActiveField('from');
1117
+ fromInputRef.current?.focus();
1118
+ }
1119
+ }
1120
+ // showTime:不关 popover,等用户调时间或切 input + 点确定
1121
+ };
1122
+
1123
+ // showTime 时间面板选择 —— 按 activeField 更新对应段时间
1124
+ const handleTimeChange = (time: TimeParts) => {
1125
+ if (activeField === 'from') {
1126
+ setDraftStartTime(time);
1127
+ setStartTouched(true);
1128
+ updateFromText(draftRange?.from, time);
1129
+ } else {
1130
+ setDraftEndTime(time);
1131
+ setEndTouched(true);
1132
+ updateToText(draftRange?.to, time);
1133
+ }
1134
+ };
1135
+
1136
+ // input blur parse(同时支持 date 和 datetime 文本)
1137
+ const handleFromBlur = () => {
1138
+ const trimmed = fromText.trim();
1139
+ if (trimmed === '') {
1140
+ setDraftRange({ from: undefined, to: draftRange?.to });
1141
+ setStartTouched(false);
1142
+ return;
1143
+ }
1144
+ const parsed = tryParseDate(trimmed, displayFormat);
1145
+ if (parsed) {
1146
+ // clamp 按 datetime 完整时间比(input parse 出的值含时间,不用 startOfDay)
1147
+ const curTo = draftRange?.to;
1148
+ const nextTo =
1149
+ curTo && curTo.getTime() < parsed.getTime() ? parsed : curTo;
1150
+ setDraftRange({ from: parsed, to: nextTo });
1151
+ setStartTouched(true);
1152
+ if (nextTo !== curTo) setEndTouched(true);
1153
+ if (timeCfg) setDraftStartTime(dateToParts(parsed));
1154
+ updateFromText(parsed, timeCfg ? dateToParts(parsed) : draftStartTime);
1155
+ if (nextTo)
1156
+ updateToText(nextTo, timeCfg ? dateToParts(nextTo) : draftEndTime);
1157
+ } else {
1158
+ setFromText('');
1159
+ setDraftRange({ from: undefined, to: draftRange?.to });
1160
+ setStartTouched(false);
1161
+ }
1162
+ };
1163
+
1164
+ const handleToBlur = () => {
1165
+ const trimmed = toText.trim();
1166
+ if (trimmed === '') {
1167
+ setDraftRange({ from: draftRange?.from, to: undefined });
1168
+ setEndTouched(false);
1169
+ return;
1170
+ }
1171
+ const parsed = tryParseDate(trimmed, displayFormat);
1172
+ if (parsed) {
1173
+ const curFrom = draftRange?.from;
1174
+ const nextFrom =
1175
+ curFrom && curFrom.getTime() > parsed.getTime() ? parsed : curFrom;
1176
+ setDraftRange({ from: nextFrom, to: parsed });
1177
+ setEndTouched(true);
1178
+ if (nextFrom !== curFrom) setStartTouched(true);
1179
+ if (timeCfg) setDraftEndTime(dateToParts(parsed));
1180
+ updateToText(parsed, timeCfg ? dateToParts(parsed) : draftEndTime);
1181
+ if (nextFrom)
1182
+ updateFromText(
1183
+ nextFrom,
1184
+ timeCfg ? dateToParts(nextFrom) : draftStartTime,
1185
+ );
1186
+ } else {
1187
+ setToText('');
1188
+ setDraftRange({ from: draftRange?.from, to: undefined });
1189
+ setEndTouched(false);
1190
+ }
1191
+ };
1192
+
1193
+ const handleConfirm = () => {
1194
+ const finalRange = finalize(
1195
+ draftRange,
1196
+ draftStartTime,
1197
+ draftEndTime,
1198
+ startTouched,
1199
+ endTouched,
1200
+ );
1201
+ confirmedRef.current = true;
1202
+ onChange?.(finalRange);
1203
+ setOpen(false);
1204
+ };
1205
+
1206
+ const handlePresetClick = (preset: DateRangePreset) => {
1207
+ const range = resolvePresetValue(preset.value);
1208
+ setDraftRange(range);
1209
+ setDraftStartTime(dateToParts(range.from));
1210
+ setDraftEndTime(dateToParts(range.to));
1211
+ setStartTouched(range.from != null);
1212
+ setEndTouched(range.to != null);
1213
+ updateFromText(range.from, dateToParts(range.from));
1214
+ updateToText(range.to, dateToParts(range.to));
1215
+ if (!timeCfg) {
1216
+ confirmedRef.current = true;
1217
+ if (!isValueControlled) setInternalValue(range);
1218
+ onChange?.(range);
1219
+ setOpen(false);
1220
+ }
1221
+ };
1222
+
1223
+ const handleClear = (e: React.MouseEvent) => {
1224
+ e.preventDefault();
1225
+ setDraftRange(undefined);
1226
+ setStartTouched(false);
1227
+ setEndTouched(false);
1228
+ setFromText('');
1229
+ setToText('');
1230
+ confirmedRef.current = true;
1231
+ if (!isValueControlled) setInternalValue(undefined);
1232
+ onChange?.(undefined);
1233
+ fromInputRef.current?.focus();
1234
+ };
1235
+
1236
+ // ─── 范围约束:disable 越界日期 ────────────────────
1237
+ // 用 startOfDay 比较"日"维度,避免 draftRange.from/to 携带的时间分量(showTime 模式)
1238
+ // 让同一天的 cell 被误判越界。例如 from=16 00:07 时,calendar 给 to 面板的 16(00:00)
1239
+ // 若用 getTime() 比较会 < from(00:07) → 误 disable;startOfDay 后两者相等 → 允许选同日。
1240
+ const combinedDisabled = React.useCallback(
1241
+ (date: Date) => {
1242
+ if (disabledDates?.(date)) return true;
1243
+ const day = startOfDay(date).getTime();
1244
+ if (activeField === 'from' && draftRange?.to) {
1245
+ return day > startOfDay(draftRange.to).getTime();
1246
+ }
1247
+ if (activeField === 'to' && draftRange?.from) {
1248
+ return day < startOfDay(draftRange.from).getTime();
1249
+ }
1250
+ return false;
1251
+ },
1252
+ [disabledDates, activeField, draftRange?.from, draftRange?.to],
1253
+ );
1254
+
1255
+ // ─── 时间约束(showTime 同日时):disable 越界时分秒 ──
1256
+ const sameDayAsOpposite = React.useMemo(() => {
1257
+ if (!timeCfg) return false;
1258
+ if (activeField === 'from' && draftRange?.to && draftRange?.from) {
1259
+ return datesEqual(
1260
+ new Date(
1261
+ draftRange.from.getFullYear(),
1262
+ draftRange.from.getMonth(),
1263
+ draftRange.from.getDate(),
1264
+ ),
1265
+ new Date(
1266
+ draftRange.to.getFullYear(),
1267
+ draftRange.to.getMonth(),
1268
+ draftRange.to.getDate(),
1269
+ ),
1270
+ );
1271
+ }
1272
+ if (activeField === 'to' && draftRange?.from && draftRange?.to) {
1273
+ return datesEqual(
1274
+ new Date(
1275
+ draftRange.from.getFullYear(),
1276
+ draftRange.from.getMonth(),
1277
+ draftRange.from.getDate(),
1278
+ ),
1279
+ new Date(
1280
+ draftRange.to.getFullYear(),
1281
+ draftRange.to.getMonth(),
1282
+ draftRange.to.getDate(),
1283
+ ),
1284
+ );
1285
+ }
1286
+ return false;
1287
+ }, [timeCfg, activeField, draftRange?.from, draftRange?.to]);
1288
+
1289
+ const oppositeTime: TimeParts | undefined = sameDayAsOpposite
1290
+ ? activeField === 'from'
1291
+ ? draftEndTime
1292
+ : draftStartTime
1293
+ : undefined;
1294
+
1295
+ const timeDisabledHours = () => {
1296
+ const set = new Set(timeCfg?.disabledHours?.() ?? []);
1297
+ if (oppositeTime) {
1298
+ if (activeField === 'from') {
1299
+ // from time disable > opposite (end time)
1300
+ for (let h = oppositeTime.h + 1; h <= 23; h++) set.add(h);
1301
+ } else {
1302
+ // to time disable < opposite (start time)
1303
+ for (let h = 0; h < oppositeTime.h; h++) set.add(h);
1304
+ }
1305
+ }
1306
+ return [...set];
1307
+ };
1308
+ const timeDisabledMinutes = (hour: number) => {
1309
+ const set = new Set(timeCfg?.disabledMinutes?.(hour) ?? []);
1310
+ if (oppositeTime && hour === oppositeTime.h) {
1311
+ if (activeField === 'from') {
1312
+ for (let m = oppositeTime.m + 1; m <= 59; m++) set.add(m);
1313
+ } else {
1314
+ for (let m = 0; m < oppositeTime.m; m++) set.add(m);
1315
+ }
1316
+ }
1317
+ return [...set];
1318
+ };
1319
+ const timeDisabledSeconds = (hour: number, minute: number) => {
1320
+ const set = new Set(timeCfg?.disabledSeconds?.(hour, minute) ?? []);
1321
+ if (
1322
+ oppositeTime &&
1323
+ hour === oppositeTime.h &&
1324
+ minute === oppositeTime.m
1325
+ ) {
1326
+ if (activeField === 'from') {
1327
+ for (let s = oppositeTime.s + 1; s <= 59; s++) set.add(s);
1328
+ } else {
1329
+ for (let s = 0; s < oppositeTime.s; s++) set.add(s);
1330
+ }
1331
+ }
1332
+ return [...set];
1333
+ };
1334
+
1335
+ // ─── preview range ────────────────────────────────
1336
+ // 任何 hover 都展示"若点击 hovered 会形成的 range"(基于 activeField 模拟点击),
1337
+ // 不再限制为"from 已选 / to 未选"中间态。
1338
+ const previewRange = React.useMemo<DateRange | undefined>(() => {
1339
+ if (!hoveredDate) return undefined;
1340
+ let pFrom: Date | undefined;
1341
+ let pTo: Date | undefined;
1342
+ if (activeField === 'from') {
1343
+ pFrom = hoveredDate;
1344
+ pTo = draftRange?.to;
1345
+ // clamp:若新 from > 当前 to,把 to 也设为 hovered(模拟 handleSelect clamp 行为)
1346
+ if (pTo && pTo.getTime() < hoveredDate.getTime()) pTo = hoveredDate;
1347
+ } else {
1348
+ pFrom = draftRange?.from;
1349
+ pTo = hoveredDate;
1350
+ if (pFrom && pFrom.getTime() > hoveredDate.getTime())
1351
+ pFrom = hoveredDate;
1352
+ }
1353
+ if (!pFrom && !pTo) return undefined;
1354
+ return { from: pFrom, to: pTo };
1355
+ }, [activeField, draftRange?.from, draftRange?.to, hoveredDate]);
1356
+
1357
+ const rangeHoverProps = {
1358
+ onDayMouseEnter: (day: Date) => setHoveredDate(day),
1359
+ onDayMouseLeave: () => setHoveredDate(undefined),
1360
+ modifiers:
1361
+ previewRange && previewRange.from && previewRange.to
1362
+ ? {
1363
+ previewRange,
1364
+ previewStart: previewRange.from,
1365
+ previewEnd: previewRange.to,
1366
+ }
1367
+ : undefined,
1368
+ modifiersClassNames: {
1369
+ previewRange: PREVIEW_RANGE_CLASS,
1370
+ previewStart: PREVIEW_START_CLASS,
1371
+ previewEnd: PREVIEW_END_CLASS,
1372
+ },
1373
+ };
1374
+
1375
+ // hasClear:有值显示 X,空值还原为日历 icon。默认 true,可传 hasClear={false} 关闭。
1376
+ const hasAny = value?.from != null || value?.to != null;
1377
+ const showClear = hasClear && hasAny && !disabled;
1378
+ // calendar 锚定:showTime 单月 + 按 activeField 锚;纯日期双月以 from 起锚
1379
+ const calendarDefaultMonth =
1380
+ activeField === 'to'
1381
+ ? draftRange?.to ?? draftRange?.from
1382
+ : draftRange?.from ?? draftRange?.to;
1383
+
1384
+ return (
1385
+ <Popover open={open} onOpenChange={setOpen}>
1386
+ <PopoverAnchor asChild>
1387
+ <div
1388
+ ref={anchorRef}
1389
+ aria-invalid={status === 'error' ? true : undefined}
1390
+ style={timeCfg ? { width: '26rem' } : undefined}
1391
+ className={cn(
1392
+ triggerWrapperClass,
1393
+ triggerSizeClass[size],
1394
+ !timeCfg && 'w-72',
1395
+ status === 'error' &&
1396
+ 'border-destructive aria-invalid:border-destructive',
1397
+ status === 'warning' && 'border-warning',
1398
+ className,
1399
+ )}
1400
+ >
1401
+ <input
1402
+ ref={fromInputRef}
1403
+ type="text"
1404
+ value={fromText}
1405
+ onChange={(e) => setFromText(e.target.value)}
1406
+ onFocus={() => {
1407
+ setOpen(true);
1408
+ setActiveField('from');
1409
+ }}
1410
+ onBlur={handleFromBlur}
1411
+ placeholder={placeholders[0]}
1412
+ disabled={disabled}
1413
+ readOnly={inputReadOnly}
1414
+ className={cn(inputElementClass, 'text-center')}
1415
+ />
1416
+ <span aria-hidden className="shrink-0 text-muted-foreground">
1417
+ -
1418
+ </span>
1419
+ <input
1420
+ ref={toInputRef}
1421
+ type="text"
1422
+ value={toText}
1423
+ onChange={(e) => setToText(e.target.value)}
1424
+ onFocus={() => {
1425
+ setOpen(true);
1426
+ setActiveField('to');
1427
+ }}
1428
+ onBlur={handleToBlur}
1429
+ placeholder={placeholders[1]}
1430
+ disabled={disabled}
1431
+ readOnly={inputReadOnly}
1432
+ className={cn(inputElementClass, 'text-center')}
1433
+ />
1434
+ {showClear ? (
1435
+ <button
1436
+ type="button"
1437
+ aria-label="清除范围"
1438
+ tabIndex={-1}
1439
+ onMouseDown={handleClear}
1440
+ className="flex shrink-0 cursor-pointer items-center text-muted-foreground transition-colors hover:text-foreground"
1441
+ >
1442
+ <X className="size-4" />
1443
+ </button>
1444
+ ) : (
1445
+ <CalendarIcon
1446
+ aria-hidden
1447
+ className="size-4 shrink-0 text-muted-foreground"
1448
+ />
1449
+ )}
1450
+ </div>
1451
+ </PopoverAnchor>
1452
+ <PopoverContent
1453
+ className="w-auto rounded-lg p-0"
1454
+ onOpenAutoFocus={(e) => e.preventDefault()}
1455
+ onInteractOutside={(e) => {
1456
+ const target = e.detail.originalEvent.target as Node | null;
1457
+ if (target && anchorRef.current?.contains(target)) {
1458
+ e.preventDefault();
1459
+ }
1460
+ }}
133
1461
  >
134
- {value?.from
135
- ? value.to
136
- ? `${format(value.from, fmt)} ~ ${format(value.to, fmt)}`
137
- : format(value.from, fmt)
138
- : placeholder}
139
- </Button>
140
- </PopoverTrigger>
141
- <PopoverContent className="w-auto p-0">
142
- <Calendar
143
- mode="range"
144
- selected={value}
145
- onSelect={onChange}
146
- disabled={disabledDates}
147
- numberOfMonths={numberOfMonths}
148
- initialFocus
149
- />
150
- </PopoverContent>
151
- </Popover>
152
- ),
1462
+ {timeCfg ? (
1463
+ <>
1464
+ {/* showTime 模式:单月 + 单时间面板,按 activeField 切换内容 */}
1465
+ <div className="flex items-stretch">
1466
+ <Calendar
1467
+ mode="range"
1468
+ selected={draftRange}
1469
+ onSelect={handleSelect}
1470
+ disabled={combinedDisabled}
1471
+ numberOfMonths={1}
1472
+ defaultMonth={calendarDefaultMonth}
1473
+ {...datePickerCalendarProps}
1474
+ {...rangeHoverProps}
1475
+ />
1476
+ <TimePickerPanel
1477
+ value={activeField === 'to' ? draftEndTime : draftStartTime}
1478
+ onChange={handleTimeChange}
1479
+ format={timeCfg.format ?? DEFAULT_TIME_FORMAT}
1480
+ hourStep={timeCfg.hourStep}
1481
+ minuteStep={timeCfg.minuteStep}
1482
+ secondStep={timeCfg.secondStep}
1483
+ disabledHours={timeDisabledHours}
1484
+ disabledMinutes={timeDisabledMinutes}
1485
+ disabledSeconds={timeDisabledSeconds}
1486
+ hasSelection={
1487
+ activeField === 'to' ? endTouched : startTouched
1488
+ }
1489
+ padZero={false}
1490
+ className="border-l border-border"
1491
+ />
1492
+ </div>
1493
+ <div className="flex items-center justify-between gap-3 border-t border-border px-3 py-2">
1494
+ <div className="flex flex-wrap items-center gap-3">
1495
+ {presetList.map((p) => (
1496
+ <button
1497
+ key={p.label}
1498
+ type="button"
1499
+ className="cursor-pointer text-xs text-primary transition-colors hover:text-primary/80"
1500
+ onClick={() => handlePresetClick(p)}
1501
+ >
1502
+ {p.label}
1503
+ </button>
1504
+ ))}
1505
+ </div>
1506
+ <Button
1507
+ size="sm"
1508
+ onClick={handleConfirm}
1509
+ disabled={!startTouched && !endTouched}
1510
+ >
1511
+ 确定
1512
+ </Button>
1513
+ </div>
1514
+ </>
1515
+ ) : (
1516
+ <>
1517
+ {/* 纯日期:双月并排 + activeField 控制 + hover preview */}
1518
+ <Calendar
1519
+ mode="range"
1520
+ selected={draftRange}
1521
+ onSelect={handleSelect}
1522
+ disabled={combinedDisabled}
1523
+ numberOfMonths={numberOfMonths}
1524
+ defaultMonth={calendarDefaultMonth}
1525
+ {...datePickerCalendarProps}
1526
+ {...rangeHoverProps}
1527
+ />
1528
+ {presetList.length > 0 && (
1529
+ <div className="flex flex-wrap items-center gap-3 border-t border-border px-3 py-2">
1530
+ {presetList.map((p) => (
1531
+ <button
1532
+ key={p.label}
1533
+ type="button"
1534
+ className="cursor-pointer text-xs text-primary transition-colors hover:text-primary/80"
1535
+ onClick={() => handlePresetClick(p)}
1536
+ >
1537
+ {p.label}
1538
+ </button>
1539
+ ))}
1540
+ </div>
1541
+ )}
1542
+ </>
1543
+ )}
1544
+ {footer ? (
1545
+ <div className="border-t border-border p-2 text-xs">{footer}</div>
1546
+ ) : null}
1547
+ </PopoverContent>
1548
+ </Popover>
1549
+ );
1550
+ },
153
1551
  );
154
1552
  DateRangePicker.displayName = 'DateRangePicker';
155
1553