@spatika/react 1.0.0 → 1.2.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 (369) hide show
  1. package/AGENTS.md +71 -0
  2. package/README.md +4 -3
  3. package/dist/composites/AccentRule.d.ts +6 -0
  4. package/dist/composites/AccentRule.d.ts.map +1 -0
  5. package/dist/composites/AccentRule.js +6 -0
  6. package/dist/composites/AvailabilityBadge.d.ts +9 -0
  7. package/dist/composites/AvailabilityBadge.d.ts.map +1 -0
  8. package/dist/composites/AvailabilityBadge.js +9 -0
  9. package/dist/composites/Card.d.ts +7 -1
  10. package/dist/composites/Card.d.ts.map +1 -1
  11. package/dist/composites/Card.js +10 -0
  12. package/dist/composites/CareerCard.d.ts +20 -0
  13. package/dist/composites/CareerCard.d.ts.map +1 -0
  14. package/dist/composites/CareerCard.js +17 -0
  15. package/dist/composites/ChartDataGrid.d.ts +33 -0
  16. package/dist/composites/ChartDataGrid.d.ts.map +1 -0
  17. package/dist/composites/ChartDataGrid.js +77 -0
  18. package/dist/composites/ContactLink.d.ts +15 -0
  19. package/dist/composites/ContactLink.d.ts.map +1 -0
  20. package/dist/composites/ContactLink.js +8 -0
  21. package/dist/composites/CoverHero.d.ts +1 -1
  22. package/dist/composites/CoverHero.d.ts.map +1 -1
  23. package/dist/composites/CoverHero.js +2 -2
  24. package/dist/composites/DisplayHeading.d.ts +9 -0
  25. package/dist/composites/DisplayHeading.d.ts.map +1 -0
  26. package/dist/composites/DisplayHeading.js +6 -0
  27. package/dist/composites/EntityMediaCard.d.ts +1 -1
  28. package/dist/composites/EntityMediaCard.d.ts.map +1 -1
  29. package/dist/composites/EntityMediaCard.js +12 -4
  30. package/dist/composites/EventCalendar.d.ts +35 -0
  31. package/dist/composites/EventCalendar.d.ts.map +1 -0
  32. package/dist/composites/EventCalendar.js +339 -0
  33. package/dist/composites/EventEditor.d.ts +22 -0
  34. package/dist/composites/EventEditor.d.ts.map +1 -0
  35. package/dist/composites/EventEditor.js +86 -0
  36. package/dist/composites/EventTimeline.d.ts +24 -0
  37. package/dist/composites/EventTimeline.d.ts.map +1 -0
  38. package/dist/composites/EventTimeline.js +184 -0
  39. package/dist/composites/FloatChip.d.ts +15 -0
  40. package/dist/composites/FloatChip.d.ts.map +1 -0
  41. package/dist/composites/FloatChip.js +7 -0
  42. package/dist/composites/GradientText.d.ts +11 -0
  43. package/dist/composites/GradientText.d.ts.map +1 -0
  44. package/dist/composites/GradientText.js +6 -0
  45. package/dist/composites/IconTile.d.ts +10 -0
  46. package/dist/composites/IconTile.d.ts.map +1 -0
  47. package/dist/composites/IconTile.js +7 -0
  48. package/dist/composites/MapChart.d.ts +24 -0
  49. package/dist/composites/MapChart.d.ts.map +1 -0
  50. package/dist/composites/MapChart.js +58 -0
  51. package/dist/composites/MetaChip.d.ts +9 -0
  52. package/dist/composites/MetaChip.d.ts.map +1 -0
  53. package/dist/composites/MetaChip.js +6 -0
  54. package/dist/composites/PresenceDot.d.ts +10 -0
  55. package/dist/composites/PresenceDot.d.ts.map +1 -0
  56. package/dist/composites/PresenceDot.js +10 -0
  57. package/dist/composites/ProfileHero.d.ts +6 -4
  58. package/dist/composites/ProfileHero.d.ts.map +1 -1
  59. package/dist/composites/ProfileHero.js +8 -8
  60. package/dist/composites/ProjectCard.d.ts +12 -0
  61. package/dist/composites/ProjectCard.d.ts.map +1 -0
  62. package/dist/composites/ProjectCard.js +9 -0
  63. package/dist/composites/ScatterWebGL.d.ts +22 -0
  64. package/dist/composites/ScatterWebGL.d.ts.map +1 -0
  65. package/dist/composites/ScatterWebGL.js +45 -0
  66. package/dist/composites/SectionHeading.d.ts +16 -0
  67. package/dist/composites/SectionHeading.d.ts.map +1 -0
  68. package/dist/composites/SectionHeading.js +12 -0
  69. package/dist/composites/SiteFooter.d.ts +18 -0
  70. package/dist/composites/SiteFooter.d.ts.map +1 -0
  71. package/dist/composites/SiteFooter.js +15 -0
  72. package/dist/composites/SiteNav.d.ts +25 -0
  73. package/dist/composites/SiteNav.d.ts.map +1 -0
  74. package/dist/composites/SiteNav.js +44 -0
  75. package/dist/composites/StatCard.d.ts +7 -1
  76. package/dist/composites/StatCard.d.ts.map +1 -1
  77. package/dist/composites/StatCard.js +5 -2
  78. package/dist/composites/StatusDot.d.ts +1 -1
  79. package/dist/composites/Tag.d.ts +11 -0
  80. package/dist/composites/Tag.d.ts.map +1 -0
  81. package/dist/composites/Tag.js +33 -0
  82. package/dist/composites/Wordmark.d.ts +19 -0
  83. package/dist/composites/Wordmark.d.ts.map +1 -0
  84. package/dist/composites/Wordmark.js +15 -0
  85. package/dist/composites/cartesian-charts.d.ts +138 -0
  86. package/dist/composites/cartesian-charts.d.ts.map +1 -0
  87. package/dist/composites/cartesian-charts.js +401 -0
  88. package/dist/composites/chart-composition.d.ts +139 -0
  89. package/dist/composites/chart-composition.d.ts.map +1 -0
  90. package/dist/composites/chart-composition.js +486 -0
  91. package/dist/composites/chart-ui.d.ts +83 -0
  92. package/dist/composites/chart-ui.d.ts.map +1 -0
  93. package/dist/composites/chart-ui.js +108 -0
  94. package/dist/composites/charts-3d.d.ts +37 -0
  95. package/dist/composites/charts-3d.d.ts.map +1 -0
  96. package/dist/composites/charts-3d.js +121 -0
  97. package/dist/composites/flow-charts.d.ts +89 -0
  98. package/dist/composites/flow-charts.d.ts.map +1 -0
  99. package/dist/composites/flow-charts.js +186 -0
  100. package/dist/composites/radial-charts.d.ts +109 -0
  101. package/dist/composites/radial-charts.d.ts.map +1 -0
  102. package/dist/composites/radial-charts.js +176 -0
  103. package/dist/composites/scheduler-ui.d.ts +43 -0
  104. package/dist/composites/scheduler-ui.d.ts.map +1 -0
  105. package/dist/composites/scheduler-ui.js +71 -0
  106. package/dist/index.d.ts +83 -7
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +74 -7
  109. package/dist/lib/chart-export.d.ts +10 -0
  110. package/dist/lib/chart-export.d.ts.map +1 -0
  111. package/dist/lib/chart-export.js +102 -0
  112. package/dist/lib/charts.d.ts +235 -0
  113. package/dist/lib/charts.d.ts.map +1 -0
  114. package/dist/lib/charts.js +744 -0
  115. package/dist/lib/geo.d.ts +39 -0
  116. package/dist/lib/geo.d.ts.map +1 -0
  117. package/dist/lib/geo.js +101 -0
  118. package/dist/lib/iso.d.ts +21 -0
  119. package/dist/lib/iso.d.ts.map +1 -0
  120. package/dist/lib/iso.js +56 -0
  121. package/dist/lib/rrule.d.ts +20 -0
  122. package/dist/lib/rrule.d.ts.map +1 -0
  123. package/dist/lib/rrule.js +236 -0
  124. package/dist/lib/scheduler.d.ts +152 -0
  125. package/dist/lib/scheduler.d.ts.map +1 -0
  126. package/dist/lib/scheduler.js +501 -0
  127. package/dist/lib/webgl-scatter.d.ts +44 -0
  128. package/dist/lib/webgl-scatter.d.ts.map +1 -0
  129. package/dist/lib/webgl-scatter.js +185 -0
  130. package/dist/primitives/Accordion.d.ts +44 -20
  131. package/dist/primitives/Accordion.d.ts.map +1 -1
  132. package/dist/primitives/Accordion.js +82 -22
  133. package/dist/primitives/AppBar.d.ts +13 -0
  134. package/dist/primitives/AppBar.d.ts.map +1 -0
  135. package/dist/primitives/AppBar.js +27 -0
  136. package/dist/primitives/Autocomplete.d.ts +28 -0
  137. package/dist/primitives/Autocomplete.d.ts.map +1 -0
  138. package/dist/primitives/Autocomplete.js +118 -0
  139. package/dist/primitives/Avatar.d.ts +13 -1
  140. package/dist/primitives/Avatar.d.ts.map +1 -1
  141. package/dist/primitives/Avatar.js +17 -2
  142. package/dist/primitives/Backdrop.d.ts +17 -0
  143. package/dist/primitives/Backdrop.d.ts.map +1 -0
  144. package/dist/primitives/Backdrop.js +17 -0
  145. package/dist/primitives/BottomNavigation.d.ts +23 -0
  146. package/dist/primitives/BottomNavigation.d.ts.map +1 -0
  147. package/dist/primitives/BottomNavigation.js +30 -0
  148. package/dist/primitives/Button.d.ts +4 -4
  149. package/dist/primitives/Button.d.ts.map +1 -1
  150. package/dist/primitives/Button.js +2 -0
  151. package/dist/primitives/ButtonBase.d.ts +14 -0
  152. package/dist/primitives/ButtonBase.d.ts.map +1 -0
  153. package/dist/primitives/ButtonBase.js +13 -0
  154. package/dist/primitives/ButtonGroup.d.ts +25 -0
  155. package/dist/primitives/ButtonGroup.d.ts.map +1 -0
  156. package/dist/primitives/ButtonGroup.js +44 -0
  157. package/dist/primitives/CircularProgress.d.ts +16 -0
  158. package/dist/primitives/CircularProgress.d.ts.map +1 -0
  159. package/dist/primitives/CircularProgress.js +17 -0
  160. package/dist/primitives/ClickAwayListener.d.ts +16 -0
  161. package/dist/primitives/ClickAwayListener.d.ts.map +1 -0
  162. package/dist/primitives/ClickAwayListener.js +33 -0
  163. package/dist/primitives/Collapse.d.ts +19 -0
  164. package/dist/primitives/Collapse.d.ts.map +1 -0
  165. package/dist/primitives/Collapse.js +14 -0
  166. package/dist/primitives/Container.d.ts +13 -0
  167. package/dist/primitives/Container.d.ts.map +1 -0
  168. package/dist/primitives/Container.js +26 -0
  169. package/dist/primitives/Fab.d.ts +22 -0
  170. package/dist/primitives/Fab.d.ts.map +1 -0
  171. package/dist/primitives/Fab.js +42 -0
  172. package/dist/primitives/FormControl.d.ts +27 -0
  173. package/dist/primitives/FormControl.d.ts.map +1 -0
  174. package/dist/primitives/FormControl.js +24 -0
  175. package/dist/primitives/FormControlLabel.d.ts +18 -0
  176. package/dist/primitives/FormControlLabel.d.ts.map +1 -0
  177. package/dist/primitives/FormControlLabel.js +19 -0
  178. package/dist/primitives/Grid.d.ts +32 -0
  179. package/dist/primitives/Grid.d.ts.map +1 -0
  180. package/dist/primitives/Grid.js +50 -0
  181. package/dist/primitives/IconButton.d.ts +22 -0
  182. package/dist/primitives/IconButton.d.ts.map +1 -0
  183. package/dist/primitives/IconButton.js +39 -0
  184. package/dist/primitives/ImageList.d.ts +31 -0
  185. package/dist/primitives/ImageList.d.ts.map +1 -0
  186. package/dist/primitives/ImageList.js +33 -0
  187. package/dist/primitives/InputAdornment.d.ts +28 -0
  188. package/dist/primitives/InputAdornment.d.ts.map +1 -0
  189. package/dist/primitives/InputAdornment.js +13 -0
  190. package/dist/primitives/Link.d.ts +17 -0
  191. package/dist/primitives/Link.d.ts.map +1 -0
  192. package/dist/primitives/Link.js +29 -0
  193. package/dist/primitives/List.d.ts +41 -0
  194. package/dist/primitives/List.d.ts.map +1 -0
  195. package/dist/primitives/List.js +22 -0
  196. package/dist/primitives/Masonry.d.ts +14 -0
  197. package/dist/primitives/Masonry.d.ts.map +1 -0
  198. package/dist/primitives/Masonry.js +15 -0
  199. package/dist/primitives/Menu.d.ts +28 -0
  200. package/dist/primitives/Menu.d.ts.map +1 -0
  201. package/dist/primitives/Menu.js +54 -0
  202. package/dist/primitives/MobileStepper.d.ts +15 -0
  203. package/dist/primitives/MobileStepper.d.ts.map +1 -0
  204. package/dist/primitives/MobileStepper.js +8 -0
  205. package/dist/primitives/Modal.d.ts +18 -0
  206. package/dist/primitives/Modal.d.ts.map +1 -0
  207. package/dist/primitives/Modal.js +29 -0
  208. package/dist/primitives/NoSsr.d.ts +15 -0
  209. package/dist/primitives/NoSsr.d.ts.map +1 -0
  210. package/dist/primitives/NoSsr.js +19 -0
  211. package/dist/primitives/Paper.d.ts +22 -0
  212. package/dist/primitives/Paper.d.ts.map +1 -0
  213. package/dist/primitives/Paper.js +33 -0
  214. package/dist/primitives/Progress.d.ts +6 -2
  215. package/dist/primitives/Progress.d.ts.map +1 -1
  216. package/dist/primitives/Progress.js +8 -3
  217. package/dist/primitives/Snackbar.d.ts +23 -0
  218. package/dist/primitives/Snackbar.d.ts.map +1 -0
  219. package/dist/primitives/Snackbar.js +33 -0
  220. package/dist/primitives/SpeedDial.d.ts +26 -0
  221. package/dist/primitives/SpeedDial.d.ts.map +1 -0
  222. package/dist/primitives/SpeedDial.js +26 -0
  223. package/dist/primitives/Stack.d.ts +15 -0
  224. package/dist/primitives/Stack.d.ts.map +1 -0
  225. package/dist/primitives/Stack.js +45 -0
  226. package/dist/primitives/StarRating.d.ts +19 -1
  227. package/dist/primitives/StarRating.d.ts.map +1 -1
  228. package/dist/primitives/StarRating.js +36 -6
  229. package/dist/primitives/Stepper.d.ts +36 -0
  230. package/dist/primitives/Stepper.d.ts.map +1 -0
  231. package/dist/primitives/Stepper.js +53 -0
  232. package/dist/primitives/TablePagination.d.ts +16 -0
  233. package/dist/primitives/TablePagination.d.ts.map +1 -0
  234. package/dist/primitives/TablePagination.js +12 -0
  235. package/dist/primitives/TextField.d.ts +24 -0
  236. package/dist/primitives/TextField.d.ts.map +1 -0
  237. package/dist/primitives/TextField.js +19 -0
  238. package/dist/primitives/TextareaAutosize.d.ts +11 -0
  239. package/dist/primitives/TextareaAutosize.d.ts.map +1 -0
  240. package/dist/primitives/TextareaAutosize.js +26 -0
  241. package/dist/primitives/ToggleButton.d.ts +28 -0
  242. package/dist/primitives/ToggleButton.d.ts.map +1 -0
  243. package/dist/primitives/ToggleButton.js +56 -0
  244. package/dist/primitives/TransferList.d.ts +21 -0
  245. package/dist/primitives/TransferList.d.ts.map +1 -0
  246. package/dist/primitives/TransferList.js +40 -0
  247. package/dist/primitives/Typography.d.ts +23 -0
  248. package/dist/primitives/Typography.d.ts.map +1 -0
  249. package/dist/primitives/Typography.js +69 -0
  250. package/dist/primitives/transitions.d.ts +29 -0
  251. package/dist/primitives/transitions.d.ts.map +1 -0
  252. package/dist/primitives/transitions.js +52 -0
  253. package/package.json +30 -5
  254. package/skills/spatika-ui/SKILL.md +96 -0
  255. package/src/composites/AccentRule.tsx +12 -0
  256. package/src/composites/AvailabilityBadge.tsx +32 -0
  257. package/src/composites/Card.tsx +51 -1
  258. package/src/composites/CareerCard.tsx +111 -0
  259. package/src/composites/ChartDataGrid.test.tsx +57 -0
  260. package/src/composites/ChartDataGrid.tsx +196 -0
  261. package/src/composites/ContactLink.tsx +66 -0
  262. package/src/composites/CoverHero.test.tsx +38 -0
  263. package/src/composites/CoverHero.tsx +29 -22
  264. package/src/composites/DisplayHeading.tsx +27 -0
  265. package/src/composites/EntityMediaCard.test.tsx +58 -0
  266. package/src/composites/EntityMediaCard.tsx +31 -13
  267. package/src/composites/EventCalendar.tsx +928 -0
  268. package/src/composites/EventEditor.tsx +268 -0
  269. package/src/composites/EventTimeline.tsx +357 -0
  270. package/src/composites/FloatChip.tsx +63 -0
  271. package/src/composites/GradientText.tsx +31 -0
  272. package/src/composites/IconTile.tsx +33 -0
  273. package/src/composites/MapChart.test.tsx +39 -0
  274. package/src/composites/MapChart.tsx +130 -0
  275. package/src/composites/MetaChip.tsx +24 -0
  276. package/src/composites/PresenceDot.tsx +42 -0
  277. package/src/composites/ProfileHero.test.tsx +33 -0
  278. package/src/composites/ProfileHero.tsx +96 -79
  279. package/src/composites/ProjectCard.tsx +65 -0
  280. package/src/composites/ScatterWebGL.test.tsx +25 -0
  281. package/src/composites/ScatterWebGL.tsx +79 -0
  282. package/src/composites/SectionHeading.tsx +61 -0
  283. package/src/composites/SiteFooter.test.tsx +37 -0
  284. package/src/composites/SiteFooter.tsx +79 -0
  285. package/src/composites/SiteNav.test.tsx +46 -0
  286. package/src/composites/SiteNav.tsx +150 -0
  287. package/src/composites/StatCard.test.tsx +30 -0
  288. package/src/composites/StatCard.tsx +36 -1
  289. package/src/composites/Tag.tsx +50 -0
  290. package/src/composites/Wordmark.test.tsx +23 -0
  291. package/src/composites/Wordmark.tsx +46 -0
  292. package/src/composites/cartesian-charts.test.tsx +52 -0
  293. package/src/composites/cartesian-charts.tsx +1287 -0
  294. package/src/composites/chart-composition.test.tsx +53 -0
  295. package/src/composites/chart-composition.tsx +898 -0
  296. package/src/composites/chart-ui.tsx +328 -0
  297. package/src/composites/charts-3d.test.tsx +25 -0
  298. package/src/composites/charts-3d.tsx +318 -0
  299. package/src/composites/charts.test.tsx +254 -0
  300. package/src/composites/chrome.test.tsx +29 -0
  301. package/src/composites/flow-charts.test.tsx +79 -0
  302. package/src/composites/flow-charts.tsx +607 -0
  303. package/src/composites/marketing.test.tsx +192 -0
  304. package/src/composites/radial-charts.test.tsx +74 -0
  305. package/src/composites/radial-charts.tsx +617 -0
  306. package/src/composites/scheduler-ui.tsx +304 -0
  307. package/src/composites/scheduler.test.tsx +194 -0
  308. package/src/index.test.ts +22 -0
  309. package/src/index.ts +300 -5
  310. package/src/lib/chart-export.test.ts +20 -0
  311. package/src/lib/chart-export.ts +99 -0
  312. package/src/lib/charts-platform.test.ts +93 -0
  313. package/src/lib/charts.test.ts +132 -0
  314. package/src/lib/charts.ts +1021 -0
  315. package/src/lib/geo.test.ts +42 -0
  316. package/src/lib/geo.ts +138 -0
  317. package/src/lib/iso.test.ts +22 -0
  318. package/src/lib/iso.ts +102 -0
  319. package/src/lib/rrule.test.ts +53 -0
  320. package/src/lib/rrule.ts +281 -0
  321. package/src/lib/scheduler.test.ts +186 -0
  322. package/src/lib/scheduler.ts +688 -0
  323. package/src/lib/tokens.test.ts +2 -2
  324. package/src/lib/webgl-scatter.test.ts +36 -0
  325. package/src/lib/webgl-scatter.ts +230 -0
  326. package/src/primitives/Accordion.tsx +218 -63
  327. package/src/primitives/AppBar.tsx +39 -0
  328. package/src/primitives/Autocomplete.tsx +236 -0
  329. package/src/primitives/Avatar.tsx +52 -1
  330. package/src/primitives/Backdrop.tsx +48 -0
  331. package/src/primitives/BottomNavigation.tsx +102 -0
  332. package/src/primitives/Button.tsx +3 -0
  333. package/src/primitives/ButtonBase.tsx +34 -0
  334. package/src/primitives/ButtonGroup.tsx +90 -0
  335. package/src/primitives/CircularProgress.tsx +104 -0
  336. package/src/primitives/ClickAwayListener.tsx +46 -0
  337. package/src/primitives/Collapse.tsx +39 -0
  338. package/src/primitives/Container.tsx +40 -0
  339. package/src/primitives/Fab.tsx +62 -0
  340. package/src/primitives/FormControl.tsx +90 -0
  341. package/src/primitives/FormControlLabel.tsx +51 -0
  342. package/src/primitives/Grid.tsx +101 -0
  343. package/src/primitives/IconButton.tsx +59 -0
  344. package/src/primitives/ImageList.test.tsx +48 -0
  345. package/src/primitives/ImageList.tsx +112 -0
  346. package/src/primitives/InputAdornment.tsx +86 -0
  347. package/src/primitives/Link.tsx +48 -0
  348. package/src/primitives/List.tsx +121 -0
  349. package/src/primitives/Masonry.tsx +32 -0
  350. package/src/primitives/Menu.tsx +112 -0
  351. package/src/primitives/MobileStepper.tsx +64 -0
  352. package/src/primitives/Modal.tsx +70 -0
  353. package/src/primitives/NoSsr.tsx +28 -0
  354. package/src/primitives/Paper.tsx +55 -0
  355. package/src/primitives/Progress.tsx +25 -7
  356. package/src/primitives/Snackbar.tsx +83 -0
  357. package/src/primitives/SpeedDial.tsx +110 -0
  358. package/src/primitives/Stack.tsx +88 -0
  359. package/src/primitives/StarRating.tsx +87 -28
  360. package/src/primitives/Stepper.tsx +184 -0
  361. package/src/primitives/TablePagination.tsx +58 -0
  362. package/src/primitives/TextField.tsx +105 -0
  363. package/src/primitives/TextareaAutosize.tsx +49 -0
  364. package/src/primitives/ToggleButton.tsx +148 -0
  365. package/src/primitives/TransferList.tsx +110 -0
  366. package/src/primitives/Typography.tsx +90 -0
  367. package/src/primitives/mui-more.test.tsx +76 -0
  368. package/src/primitives/mui.test.tsx +134 -0
  369. package/src/primitives/transitions.tsx +120 -0
@@ -0,0 +1,928 @@
1
+ import {
2
+ useMemo,
3
+ useRef,
4
+ useState,
5
+ type KeyboardEvent as ReactKeyboardEvent,
6
+ type PointerEvent as ReactPointerEvent,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { cn } from "../lib/cn";
10
+ import { expandVisibleEvents } from "../lib/rrule";
11
+ import { useControllableState } from "../lib/use-controllable-state";
12
+ import {
13
+ addDays,
14
+ addMonths,
15
+ agendaDays,
16
+ DEFAULT_SCHEDULER_PREFERENCES,
17
+ eventCanDrag,
18
+ eventCanResize,
19
+ eventColorStyle,
20
+ filterWeekendDays,
21
+ formatDateSpan,
22
+ formatDayHeading,
23
+ formatHour,
24
+ formatMonthYear,
25
+ formatTime,
26
+ formatWeekRange,
27
+ getMonthGrid,
28
+ getWeekDays,
29
+ hoursInView,
30
+ isMonthBar,
31
+ isSameDay,
32
+ isSameMonth,
33
+ isoDay,
34
+ layoutTimedEvents,
35
+ normalizeEvents,
36
+ occupiesDay,
37
+ occurrenceKey,
38
+ resizeEvent,
39
+ shiftByDays,
40
+ startOfDay,
41
+ timedEventsOnDay,
42
+ toDate,
43
+ visibleEvents,
44
+ weekNumber,
45
+ weekSpanLanesForDays,
46
+ type CalendarView,
47
+ type NormalizedEvent,
48
+ type SchedulerEvent,
49
+ type SchedulerPreferences,
50
+ type SchedulerResource,
51
+ } from "../lib/scheduler";
52
+ import { EventEditor, type EventEditorDraft } from "./EventEditor";
53
+ import {
54
+ CALENDAR_VIEW_LABEL,
55
+ EventResizeHandle,
56
+ ResourceLegend,
57
+ SchedulerPreferencesMenu,
58
+ SchedulerShell,
59
+ SchedulerToolbar,
60
+ } from "./scheduler-ui";
61
+ import { DropdownMenuItem } from "../primitives/DropdownMenu";
62
+
63
+ export type EventCalendarProps = {
64
+ events?: SchedulerEvent[];
65
+ resources?: SchedulerResource[];
66
+ view?: CalendarView;
67
+ defaultView?: CalendarView;
68
+ onViewChange?: (view: CalendarView) => void;
69
+ date?: Date;
70
+ defaultDate?: Date;
71
+ onDateChange?: (date: Date) => void;
72
+ views?: CalendarView[];
73
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
74
+ hourStart?: number;
75
+ hourEnd?: number;
76
+ locale?: string;
77
+ readOnly?: boolean;
78
+ areEventsDraggable?: boolean;
79
+ areEventsResizable?: boolean;
80
+ preferences?: SchedulerPreferences;
81
+ defaultPreferences?: SchedulerPreferences;
82
+ onPreferencesChange?: (preferences: Required<SchedulerPreferences>) => void;
83
+ onEventClick?: (event: SchedulerEvent) => void;
84
+ onEventChange?: (event: SchedulerEvent, next: Partial<SchedulerEvent>) => void;
85
+ onEventCreate?: (event: SchedulerEvent) => void;
86
+ onEventDelete?: (event: SchedulerEvent) => void;
87
+ onSlotClick?: (slot: { start: Date; end: Date; allDay: boolean }) => void;
88
+ className?: string;
89
+ };
90
+
91
+ const MAX_TIMED_IN_CELL = 3;
92
+ const AGENDA_LENGTH = 14;
93
+
94
+ function chunkWeeks<T>(items: T[]): T[][] {
95
+ const weeks: T[][] = [];
96
+ for (let i = 0; i < items.length; i += 7) weeks.push(items.slice(i, i + 7));
97
+ return weeks;
98
+ }
99
+
100
+ function sourceEvent(events: SchedulerEvent[], id: string): SchedulerEvent | undefined {
101
+ return events.find((event) => event.id === id);
102
+ }
103
+
104
+ function startOfToday(): Date {
105
+ const now = new Date();
106
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate());
107
+ }
108
+
109
+ function visibleRange(
110
+ view: CalendarView,
111
+ date: Date,
112
+ weekStartsOn: number,
113
+ ): { start: Date; end: Date } {
114
+ if (view === "month") {
115
+ const grid = getMonthGrid(date, weekStartsOn);
116
+ return { start: grid[0], end: addDays(grid[grid.length - 1], 1) };
117
+ }
118
+ if (view === "week") {
119
+ const days = getWeekDays(date, weekStartsOn);
120
+ return { start: days[0], end: addDays(days[6], 1) };
121
+ }
122
+ if (view === "agenda") {
123
+ const days = agendaDays(date, AGENDA_LENGTH);
124
+ return { start: days[0], end: addDays(days[days.length - 1], 1) };
125
+ }
126
+ const start = startOfDay(date);
127
+ return { start, end: addDays(start, 1) };
128
+ }
129
+
130
+ function draftFromSource(source: SchedulerEvent): EventEditorDraft {
131
+ return {
132
+ id: source.id,
133
+ title: source.title,
134
+ start: toDate(source.start),
135
+ end: toDate(source.end),
136
+ allDay: source.allDay,
137
+ resourceId: source.resourceId,
138
+ color: source.color,
139
+ description: source.description,
140
+ rrule: source.rrule,
141
+ };
142
+ }
143
+
144
+ /** Month / week / day / agenda calendar with editor, resize, recurrence, and preferences. */
145
+ export function EventCalendar({
146
+ events = [],
147
+ resources = [],
148
+ view: viewProp,
149
+ defaultView = "month",
150
+ onViewChange,
151
+ date: dateProp,
152
+ defaultDate,
153
+ onDateChange,
154
+ views = ["month", "week", "day", "agenda"],
155
+ weekStartsOn = 0,
156
+ hourStart = 7,
157
+ hourEnd = 21,
158
+ locale,
159
+ readOnly = false,
160
+ areEventsDraggable,
161
+ areEventsResizable,
162
+ preferences,
163
+ defaultPreferences,
164
+ onPreferencesChange,
165
+ onEventClick,
166
+ onEventChange,
167
+ onEventCreate,
168
+ onEventDelete,
169
+ onSlotClick,
170
+ className,
171
+ }: EventCalendarProps) {
172
+ const [view, setView] = useControllableState<CalendarView>({
173
+ prop: viewProp,
174
+ defaultProp: defaultView,
175
+ onChange: onViewChange,
176
+ });
177
+ const [date, setDate] = useControllableState<Date>({
178
+ prop: dateProp,
179
+ defaultProp: defaultDate ?? new Date(),
180
+ onChange: onDateChange,
181
+ });
182
+ const [prefs, setPrefs] = useControllableState<Required<SchedulerPreferences>>({
183
+ prop: preferences
184
+ ? { ...DEFAULT_SCHEDULER_PREFERENCES, weekStartsOn, ...defaultPreferences, ...preferences }
185
+ : undefined,
186
+ defaultProp: { ...DEFAULT_SCHEDULER_PREFERENCES, weekStartsOn, ...defaultPreferences },
187
+ onChange: onPreferencesChange,
188
+ });
189
+ const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
190
+ const [draft, setDraft] = useState<EventEditorDraft | null>(null);
191
+ const dragRef = useRef<{ id: string; x: number; y: number; active: boolean } | null>(null);
192
+
193
+ const resolvedView = view ?? "month";
194
+ const resolvedDate = date ?? new Date();
195
+ const resolvedPrefs = prefs ?? { ...DEFAULT_SCHEDULER_PREFERENCES, weekStartsOn };
196
+ const startOn = resolvedPrefs.weekStartsOn;
197
+ const today = startOfToday();
198
+ const hour12 = resolvedPrefs.ampm;
199
+ const canDrag = !readOnly && (areEventsDraggable ?? Boolean(onEventChange));
200
+ const canResize = !readOnly && (areEventsResizable ?? Boolean(onEventChange));
201
+ const editorLocked = readOnly || Boolean(draft?.id && sourceEvent(events, draft.id)?.readOnly);
202
+
203
+ const range = useMemo(
204
+ () => visibleRange(resolvedView, resolvedDate, startOn),
205
+ [resolvedView, resolvedDate, startOn],
206
+ );
207
+
208
+ const normalized = useMemo(() => {
209
+ const visible = visibleEvents(normalizeEvents(events, resources), hiddenIds);
210
+ return expandVisibleEvents(visible, range.start, range.end, events);
211
+ }, [events, resources, hiddenIds, range.start, range.end]);
212
+
213
+ const title =
214
+ resolvedView === "month"
215
+ ? formatMonthYear(resolvedDate, locale)
216
+ : resolvedView === "week"
217
+ ? formatWeekRange(resolvedDate, startOn, locale)
218
+ : resolvedView === "agenda"
219
+ ? formatDateSpan(range.start, addDays(range.end, -1), locale)
220
+ : formatDayHeading(resolvedDate, locale);
221
+
222
+ function go(direction: -1 | 1) {
223
+ if (resolvedView === "month") setDate(addMonths(resolvedDate, direction));
224
+ else if (resolvedView === "week") setDate(addDays(resolvedDate, direction * 7));
225
+ else if (resolvedView === "agenda") setDate(addDays(resolvedDate, direction * 7));
226
+ else setDate(addDays(resolvedDate, direction));
227
+ }
228
+
229
+ function toggleResource(id: string) {
230
+ setHiddenIds((prev) => {
231
+ const next = new Set(prev);
232
+ if (next.has(id)) next.delete(id);
233
+ else next.add(id);
234
+ return next;
235
+ });
236
+ }
237
+
238
+ function openEvent(event: NormalizedEvent) {
239
+ const source = sourceEvent(events, event.id);
240
+ if (source) onEventClick?.(source);
241
+ setDraft(source ? draftFromSource(source) : {
242
+ id: event.id,
243
+ title: event.title,
244
+ start: event.start,
245
+ end: event.end,
246
+ allDay: event.allDay,
247
+ resourceId: event.resourceId,
248
+ color: event.color,
249
+ description: event.description,
250
+ rrule: event.rrule,
251
+ });
252
+ }
253
+
254
+ function openSlot(slot: { start: Date; end: Date; allDay: boolean }) {
255
+ onSlotClick?.(slot);
256
+ if (readOnly) return;
257
+ setDraft({ start: slot.start, end: slot.end, allDay: slot.allDay });
258
+ }
259
+
260
+ function handleEventPointerDown(event: NormalizedEvent, pointer: ReactPointerEvent) {
261
+ if (!onEventChange || !eventCanDrag(event, { readOnly, enabled: canDrag })) return;
262
+ (pointer.currentTarget as HTMLElement).setPointerCapture?.(pointer.pointerId);
263
+ dragRef.current = { id: event.id, x: pointer.clientX, y: pointer.clientY, active: false };
264
+ }
265
+
266
+ function handleEventPointerMove(pointer: ReactPointerEvent) {
267
+ const drag = dragRef.current;
268
+ if (!drag) return;
269
+ if (Math.hypot(pointer.clientX - drag.x, pointer.clientY - drag.y) > 6) {
270
+ drag.active = true;
271
+ }
272
+ }
273
+
274
+ function handleEventPointerUp(event: NormalizedEvent, pointer: ReactPointerEvent) {
275
+ const drag = dragRef.current;
276
+ dragRef.current = null;
277
+ if (!drag?.active || !onEventChange) return;
278
+ const target = document
279
+ .elementFromPoint(pointer.clientX, pointer.clientY)
280
+ ?.closest("[data-scheduler-day]") as HTMLElement | null;
281
+ const iso = target?.dataset.schedulerDay;
282
+ if (!iso) return;
283
+ const [year, month, day] = iso.split("-").map(Number);
284
+ const targetDay = new Date(year, month - 1, day);
285
+ const currentDay = startOfDay(event.start);
286
+ const delta = Math.round((targetDay.getTime() - currentDay.getTime()) / 86_400_000);
287
+ if (delta === 0) return;
288
+ const source = sourceEvent(events, event.id);
289
+ if (!source) return;
290
+ const master = normalizeEvents([source], resources)[0];
291
+ onEventChange(source, shiftByDays(master, delta));
292
+ }
293
+
294
+ function commitResize(event: NormalizedEvent, edge: "start" | "end", instant: Date) {
295
+ if (!onEventChange) return;
296
+ const next = resizeEvent(event, edge, instant);
297
+ const source = sourceEvent(events, event.id);
298
+ if (!source) return;
299
+ const master = normalizeEvents([source], resources)[0];
300
+ onEventChange(source, {
301
+ start: new Date(master.start.getTime() + (next.start.getTime() - event.start.getTime())),
302
+ end: new Date(master.end.getTime() + (next.end.getTime() - event.end.getTime())),
303
+ });
304
+ }
305
+
306
+ function handleKeyDown(keyboard: ReactKeyboardEvent<HTMLDivElement>) {
307
+ if (draft) return;
308
+ const target = keyboard.target as HTMLElement;
309
+ if (target.closest("input, textarea, select, [contenteditable='true']")) return;
310
+ if (keyboard.key === "ArrowLeft") {
311
+ keyboard.preventDefault();
312
+ go(-1);
313
+ } else if (keyboard.key === "ArrowRight") {
314
+ keyboard.preventDefault();
315
+ go(1);
316
+ } else if (keyboard.key === "t" || keyboard.key === "T") {
317
+ keyboard.preventDefault();
318
+ setDate(new Date());
319
+ }
320
+ }
321
+
322
+ function renderEventTrigger(
323
+ event: NormalizedEvent,
324
+ className: string,
325
+ children: ReactNode,
326
+ options?: { resize?: "ns" },
327
+ ) {
328
+ const resizable =
329
+ options?.resize === "ns" &&
330
+ eventCanResize(event, { readOnly, enabled: canResize }) &&
331
+ Boolean(onEventChange);
332
+
333
+ const button = (
334
+ <button
335
+ type="button"
336
+ draggable={false}
337
+ className={className}
338
+ style={
339
+ isMonthBar(event) || resolvedView !== "month"
340
+ ? eventColorStyle(event.color, resolvedView === "month" ? "bar" : "block")
341
+ : undefined
342
+ }
343
+ onClick={() => {
344
+ if (dragRef.current?.active) return;
345
+ openEvent(event);
346
+ }}
347
+ onPointerDown={(pointer) => handleEventPointerDown(event, pointer)}
348
+ onPointerMove={handleEventPointerMove}
349
+ onPointerUp={(pointer) => handleEventPointerUp(event, pointer)}
350
+ >
351
+ {children}
352
+ </button>
353
+ );
354
+
355
+ if (!resizable) return button;
356
+
357
+ return (
358
+ <div className="relative h-full min-h-0">
359
+ {button}
360
+ <EventResizeHandle
361
+ edge="start"
362
+ axis="ns"
363
+ event={event}
364
+ hourStart={hourStart}
365
+ hourEnd={hourEnd}
366
+ onCommit={commitResize}
367
+ />
368
+ <EventResizeHandle
369
+ edge="end"
370
+ axis="ns"
371
+ event={event}
372
+ hourStart={hourStart}
373
+ hourEnd={hourEnd}
374
+ onCommit={commitResize}
375
+ />
376
+ </div>
377
+ );
378
+ }
379
+
380
+ return (
381
+ <SchedulerShell
382
+ slot="event-calendar"
383
+ className={className}
384
+ tabIndex={0}
385
+ aria-label="Event calendar"
386
+ onKeyDown={handleKeyDown}
387
+ >
388
+ <SchedulerToolbar
389
+ title={title}
390
+ onPrev={() => go(-1)}
391
+ onNext={() => go(1)}
392
+ onToday={() => setDate(new Date())}
393
+ menuLabel={CALENDAR_VIEW_LABEL[resolvedView]}
394
+ menu={views.map((item) => (
395
+ <DropdownMenuItem key={item} onClick={() => setView(item)}>
396
+ {CALENDAR_VIEW_LABEL[item]}
397
+ </DropdownMenuItem>
398
+ ))}
399
+ trailing={
400
+ <SchedulerPreferencesMenu
401
+ value={resolvedPrefs}
402
+ onChange={(next) => setPrefs(next)}
403
+ />
404
+ }
405
+ />
406
+
407
+ {resolvedView === "month" ? (
408
+ <MonthGrid
409
+ date={resolvedDate}
410
+ today={today}
411
+ events={normalized}
412
+ weekStartsOn={startOn}
413
+ showWeekends={resolvedPrefs.showWeekends}
414
+ showWeekNumber={resolvedPrefs.showWeekNumber}
415
+ locale={locale}
416
+ hour12={hour12}
417
+ onSlotClick={openSlot}
418
+ renderEventTrigger={renderEventTrigger}
419
+ />
420
+ ) : resolvedView === "agenda" ? (
421
+ <AgendaList
422
+ date={resolvedDate}
423
+ events={normalized}
424
+ showWeekends={resolvedPrefs.showWeekends}
425
+ locale={locale}
426
+ hour12={hour12}
427
+ onSlotClick={openSlot}
428
+ onEventClick={openEvent}
429
+ />
430
+ ) : (
431
+ <TimeGrid
432
+ date={resolvedDate}
433
+ today={today}
434
+ events={normalized}
435
+ view={resolvedView === "day" ? "day" : "week"}
436
+ weekStartsOn={startOn}
437
+ showWeekends={resolvedPrefs.showWeekends}
438
+ hourStart={hourStart}
439
+ hourEnd={hourEnd}
440
+ locale={locale}
441
+ hour12={hour12}
442
+ onSlotClick={openSlot}
443
+ renderEventTrigger={renderEventTrigger}
444
+ />
445
+ )}
446
+
447
+ <ResourceLegend resources={resources} hiddenIds={hiddenIds} onToggle={toggleResource} />
448
+
449
+ <EventEditor
450
+ open={draft != null}
451
+ onOpenChange={(open) => {
452
+ if (!open) setDraft(null);
453
+ }}
454
+ draft={draft}
455
+ resources={resources}
456
+ readOnly={editorLocked}
457
+ onSave={(event) => {
458
+ if (event.id && sourceEvent(events, event.id)) {
459
+ const source = sourceEvent(events, event.id)!;
460
+ onEventChange?.(source, event);
461
+ } else {
462
+ onEventCreate?.(event);
463
+ }
464
+ }}
465
+ onDelete={
466
+ onEventDelete
467
+ ? (id) => {
468
+ const source = sourceEvent(events, id);
469
+ if (source) onEventDelete(source);
470
+ }
471
+ : undefined
472
+ }
473
+ />
474
+ </SchedulerShell>
475
+ );
476
+ }
477
+
478
+ function MonthGrid({
479
+ date,
480
+ today,
481
+ events,
482
+ weekStartsOn,
483
+ showWeekends,
484
+ showWeekNumber,
485
+ locale,
486
+ hour12,
487
+ onSlotClick,
488
+ renderEventTrigger,
489
+ }: {
490
+ date: Date;
491
+ today: Date;
492
+ events: NormalizedEvent[];
493
+ weekStartsOn: number;
494
+ showWeekends: boolean;
495
+ showWeekNumber: boolean;
496
+ locale?: string;
497
+ hour12: boolean;
498
+ onSlotClick?: EventCalendarProps["onSlotClick"];
499
+ renderEventTrigger: (
500
+ event: NormalizedEvent,
501
+ className: string,
502
+ children: ReactNode,
503
+ options?: { resize?: "ns" },
504
+ ) => ReactNode;
505
+ }) {
506
+ const weeks = chunkWeeks(getMonthGrid(date, weekStartsOn));
507
+ const headerDays = filterWeekendDays(getWeekDays(date, weekStartsOn), showWeekends);
508
+ const columns = headerDays.length;
509
+ const gridStyle = { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` };
510
+
511
+ return (
512
+ <div className="min-w-0">
513
+ <div className="flex border-b border-border/50">
514
+ {showWeekNumber ? <div className="w-8 shrink-0" /> : null}
515
+ <div className="grid min-w-0 flex-1" style={gridStyle}>
516
+ {headerDays.map((day) => (
517
+ <div
518
+ key={day.getDay()}
519
+ className="px-2 py-2 text-center text-[11px] font-bold uppercase tracking-wider text-muted-foreground"
520
+ >
521
+ {new Intl.DateTimeFormat(locale, { weekday: "short" }).format(day)}
522
+ </div>
523
+ ))}
524
+ </div>
525
+ </div>
526
+ <div>
527
+ {weeks.map((week) => {
528
+ const days = filterWeekendDays(week, showWeekends);
529
+ const lanes = weekSpanLanesForDays(days, events);
530
+ return (
531
+ <div key={week[0].toISOString()} className="flex border-b border-border/40 last:border-b-0">
532
+ {showWeekNumber ? (
533
+ <div className="flex w-8 shrink-0 items-start justify-center pt-2 text-[10px] font-bold text-muted-foreground">
534
+ {weekNumber(week[0])}
535
+ </div>
536
+ ) : null}
537
+ <div className="min-w-0 flex-1">
538
+ <div className="grid" style={gridStyle}>
539
+ {days.map((day) => {
540
+ const inMonth = isSameMonth(day, date);
541
+ const isToday = isSameDay(day, today);
542
+ return (
543
+ <div
544
+ key={day.toISOString()}
545
+ data-scheduler-day={isoDay(day)}
546
+ className={cn(
547
+ "flex items-start justify-end px-1.5 pt-1.5",
548
+ !inMonth && "bg-muted/15",
549
+ )}
550
+ >
551
+ <button
552
+ type="button"
553
+ onClick={() =>
554
+ onSlotClick?.({
555
+ start: day,
556
+ end: addDays(day, 1),
557
+ allDay: true,
558
+ })
559
+ }
560
+ className={cn(
561
+ "flex size-7 items-center justify-center rounded-full text-xs font-bold",
562
+ isToday && "bg-primary text-primary-foreground",
563
+ !isToday && inMonth && "text-foreground hover:bg-muted/50",
564
+ !isToday && !inMonth && "text-muted-foreground/70",
565
+ )}
566
+ >
567
+ {day.getDate()}
568
+ </button>
569
+ </div>
570
+ );
571
+ })}
572
+ </div>
573
+ {lanes.map((lane, laneIndex) => (
574
+ <div key={laneIndex} className="grid gap-px px-0.5 pb-0.5" style={gridStyle}>
575
+ {laneSlots(lane, columns).map((slot, slotIndex) =>
576
+ slot.kind === "empty" ? (
577
+ <div key={slotIndex} />
578
+ ) : (
579
+ <div
580
+ key={occurrenceKey(slot.event)}
581
+ className="min-w-0"
582
+ style={{ gridColumn: `span ${slot.span}` }}
583
+ >
584
+ {renderEventTrigger(
585
+ slot.event,
586
+ "flex h-5 w-full items-center truncate rounded-md px-1.5 text-[11px] font-semibold leading-none",
587
+ slot.event.title,
588
+ )}
589
+ </div>
590
+ ),
591
+ )}
592
+ </div>
593
+ ))}
594
+ <div className="grid min-h-[4.25rem]" style={gridStyle}>
595
+ {days.map((day) => {
596
+ const timed = timedEventsOnDay(events, day);
597
+ const visibleTimed = timed.slice(0, MAX_TIMED_IN_CELL);
598
+ const extra = timed.length - visibleTimed.length;
599
+ return (
600
+ <div
601
+ key={day.toISOString()}
602
+ data-scheduler-day={isoDay(day)}
603
+ className={cn(
604
+ "min-w-0 space-y-0.5 px-1 pb-2",
605
+ !isSameMonth(day, date) && "bg-muted/15",
606
+ )}
607
+ onClick={(click) => {
608
+ if ((click.target as HTMLElement).closest("button")) return;
609
+ onSlotClick?.({
610
+ start: new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9),
611
+ end: new Date(day.getFullYear(), day.getMonth(), day.getDate(), 10),
612
+ allDay: false,
613
+ });
614
+ }}
615
+ >
616
+ {visibleTimed.map((event) => (
617
+ <div key={occurrenceKey(event)} className="min-w-0">
618
+ {renderEventTrigger(
619
+ event,
620
+ "flex w-full min-w-0 items-center gap-1 rounded-sm px-0.5 py-0.5 text-left text-[11px] leading-tight text-foreground hover:bg-muted/40",
621
+ <>
622
+ <span
623
+ className="size-1.5 shrink-0 rounded-full"
624
+ style={eventColorStyle(event.color, "dot")}
625
+ />
626
+ <span className="shrink-0 text-muted-foreground">
627
+ {formatTime(event.start, locale, hour12)}
628
+ </span>
629
+ <span className="min-w-0 truncate font-medium">{event.title}</span>
630
+ </>,
631
+ )}
632
+ </div>
633
+ ))}
634
+ {extra > 0 ? (
635
+ <p className="px-0.5 text-[10px] font-bold text-muted-foreground">+{extra} more</p>
636
+ ) : null}
637
+ </div>
638
+ );
639
+ })}
640
+ </div>
641
+ </div>
642
+ </div>
643
+ );
644
+ })}
645
+ </div>
646
+ </div>
647
+ );
648
+ }
649
+
650
+ function laneSlots(lane: { event: NormalizedEvent; startCol: number; span: number }[], columns: number) {
651
+ const slots: Array<
652
+ | { kind: "empty"; span: 1 }
653
+ | { kind: "event"; event: NormalizedEvent; span: number }
654
+ > = [];
655
+ let cursor = 0;
656
+ const ordered = [...lane].sort((a, b) => a.startCol - b.startCol);
657
+ for (const segment of ordered) {
658
+ while (cursor < segment.startCol) {
659
+ slots.push({ kind: "empty", span: 1 });
660
+ cursor += 1;
661
+ }
662
+ slots.push({ kind: "event", event: segment.event, span: segment.span });
663
+ cursor += segment.span;
664
+ }
665
+ while (cursor < columns) {
666
+ slots.push({ kind: "empty", span: 1 });
667
+ cursor += 1;
668
+ }
669
+ return slots;
670
+ }
671
+
672
+ function TimeGrid({
673
+ date,
674
+ today,
675
+ events,
676
+ view,
677
+ weekStartsOn,
678
+ showWeekends,
679
+ hourStart,
680
+ hourEnd,
681
+ locale,
682
+ hour12,
683
+ onSlotClick,
684
+ renderEventTrigger,
685
+ }: {
686
+ date: Date;
687
+ today: Date;
688
+ events: NormalizedEvent[];
689
+ view: "week" | "day";
690
+ weekStartsOn: number;
691
+ showWeekends: boolean;
692
+ hourStart: number;
693
+ hourEnd: number;
694
+ locale?: string;
695
+ hour12: boolean;
696
+ onSlotClick?: EventCalendarProps["onSlotClick"];
697
+ renderEventTrigger: (
698
+ event: NormalizedEvent,
699
+ className: string,
700
+ children: ReactNode,
701
+ options?: { resize?: "ns" },
702
+ ) => ReactNode;
703
+ }) {
704
+ const days =
705
+ view === "day"
706
+ ? [date]
707
+ : filterWeekendDays(getWeekDays(date, weekStartsOn), showWeekends);
708
+ const hours = hoursInView(hourStart, hourEnd);
709
+ const hourHeight = 48;
710
+ const now = new Date();
711
+ const showNow = days.some((day) => isSameDay(day, now));
712
+ const nowTop =
713
+ ((now.getHours() + now.getMinutes() / 60 - hourStart) / (hourEnd - hourStart)) * (hours.length * hourHeight);
714
+
715
+ return (
716
+ <div className="overflow-x-auto">
717
+ <div
718
+ className="grid min-w-[640px]"
719
+ style={{ gridTemplateColumns: `3.5rem repeat(${days.length}, minmax(0, 1fr))` }}
720
+ >
721
+ <div className="border-b border-border/50" />
722
+ {days.map((day) => (
723
+ <div
724
+ key={day.toISOString()}
725
+ className={cn(
726
+ "border-b border-l border-border/50 px-2 py-2 text-center",
727
+ isSameDay(day, today) && "bg-primary/5",
728
+ )}
729
+ >
730
+ <p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
731
+ {new Intl.DateTimeFormat(locale, { weekday: "short" }).format(day)}
732
+ </p>
733
+ <p
734
+ className={cn(
735
+ "mx-auto mt-0.5 flex size-7 items-center justify-center rounded-full text-sm font-black",
736
+ isSameDay(day, today) && "bg-primary text-primary-foreground",
737
+ )}
738
+ >
739
+ {day.getDate()}
740
+ </p>
741
+ </div>
742
+ ))}
743
+
744
+ <div className="border-b border-border/50" />
745
+ {days.map((day) => {
746
+ const allDay = events.filter((event) => isMonthBar(event) && occupiesDay(event, day));
747
+ return (
748
+ <div
749
+ key={`allday-${day.toISOString()}`}
750
+ data-scheduler-day={isoDay(day)}
751
+ className="min-h-10 space-y-0.5 border-b border-l border-border/50 p-1"
752
+ >
753
+ {allDay.map((event) => (
754
+ <div key={occurrenceKey(event)}>
755
+ {renderEventTrigger(
756
+ event,
757
+ "flex h-5 w-full items-center truncate rounded-md px-1.5 text-[11px] font-semibold",
758
+ event.title,
759
+ )}
760
+ </div>
761
+ ))}
762
+ </div>
763
+ );
764
+ })}
765
+ </div>
766
+
767
+ <div className="relative min-w-[640px]" style={{ height: hours.length * hourHeight }}>
768
+ <div
769
+ className="grid h-full"
770
+ style={{ gridTemplateColumns: `3.5rem repeat(${days.length}, minmax(0, 1fr))` }}
771
+ >
772
+ <div className="relative">
773
+ {hours.map((hour) => (
774
+ <div
775
+ key={hour}
776
+ className="relative border-b border-border/30 pr-2 text-right text-[10px] font-bold text-muted-foreground"
777
+ style={{ height: hourHeight }}
778
+ >
779
+ <span className="-translate-y-2 absolute right-2">{formatHour(hour, locale, hour12)}</span>
780
+ </div>
781
+ ))}
782
+ </div>
783
+ {days.map((day) => {
784
+ const layout = layoutTimedEvents(events, day, hourStart, hourEnd);
785
+ return (
786
+ <div
787
+ key={day.toISOString()}
788
+ data-scheduler-day={isoDay(day)}
789
+ className="relative border-l border-border/40"
790
+ onClick={(click) => {
791
+ if ((click.target as HTMLElement).closest("button")) return;
792
+ const rect = click.currentTarget.getBoundingClientRect();
793
+ const ratio = (click.clientY - rect.top) / rect.height;
794
+ const hour = hourStart + ratio * (hourEnd - hourStart);
795
+ const startHour = Math.floor(hour);
796
+ const start = new Date(day.getFullYear(), day.getMonth(), day.getDate(), startHour);
797
+ onSlotClick?.({
798
+ start,
799
+ end: new Date(start.getTime() + 3_600_000),
800
+ allDay: false,
801
+ });
802
+ }}
803
+ >
804
+ {hours.map((hour) => (
805
+ <div key={hour} className="border-b border-border/30" style={{ height: hourHeight }} />
806
+ ))}
807
+ {layout.map((item) => (
808
+ <div
809
+ key={occurrenceKey(item.event)}
810
+ className="absolute px-0.5"
811
+ style={{
812
+ top: `${item.top}%`,
813
+ height: `${item.height}%`,
814
+ left: `${(item.col / item.cols) * 100}%`,
815
+ width: `${100 / item.cols}%`,
816
+ }}
817
+ >
818
+ {renderEventTrigger(
819
+ item.event,
820
+ "flex h-full w-full flex-col overflow-hidden rounded-md px-1.5 py-1 text-left text-[11px] font-semibold leading-tight",
821
+ <>
822
+ <span className="truncate">{item.event.title}</span>
823
+ <span className="truncate text-[10px] font-medium opacity-80">
824
+ {formatTime(item.event.start, locale, hour12)}
825
+ </span>
826
+ </>,
827
+ { resize: "ns" },
828
+ )}
829
+ </div>
830
+ ))}
831
+ </div>
832
+ );
833
+ })}
834
+ </div>
835
+ {showNow && nowTop > 0 && nowTop < hours.length * hourHeight ? (
836
+ <div
837
+ className="pointer-events-none absolute right-0 left-14 z-10 h-px bg-destructive"
838
+ style={{ top: nowTop }}
839
+ >
840
+ <span className="absolute -top-1 -left-1 size-2 rounded-full bg-destructive" />
841
+ </div>
842
+ ) : null}
843
+ </div>
844
+ </div>
845
+ );
846
+ }
847
+
848
+ function AgendaList({
849
+ date,
850
+ events,
851
+ showWeekends,
852
+ locale,
853
+ hour12,
854
+ onSlotClick,
855
+ onEventClick,
856
+ }: {
857
+ date: Date;
858
+ events: NormalizedEvent[];
859
+ showWeekends: boolean;
860
+ locale?: string;
861
+ hour12: boolean;
862
+ onSlotClick?: EventCalendarProps["onSlotClick"];
863
+ onEventClick: (event: NormalizedEvent) => void;
864
+ }) {
865
+ const days = filterWeekendDays(agendaDays(date, AGENDA_LENGTH), showWeekends);
866
+
867
+ return (
868
+ <div className="divide-y divide-border/50">
869
+ {days.map((day) => {
870
+ const items = events.filter((event) => occupiesDay(event, day));
871
+ return (
872
+ <div key={day.toISOString()} className="grid grid-cols-[7.5rem_minmax(0,1fr)] gap-3 px-3 py-3 sm:px-4">
873
+ <button
874
+ type="button"
875
+ data-scheduler-day={isoDay(day)}
876
+ className="text-left"
877
+ onClick={() =>
878
+ onSlotClick?.({
879
+ start: new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9),
880
+ end: new Date(day.getFullYear(), day.getMonth(), day.getDate(), 10),
881
+ allDay: false,
882
+ })
883
+ }
884
+ >
885
+ <p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
886
+ {new Intl.DateTimeFormat(locale, { weekday: "short" }).format(day)}
887
+ </p>
888
+ <p
889
+ className={cn(
890
+ "text-lg font-black",
891
+ isSameDay(day, startOfToday()) && "text-primary",
892
+ )}
893
+ >
894
+ {new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }).format(day)}
895
+ </p>
896
+ </button>
897
+ <div className="min-w-0 space-y-1.5">
898
+ {items.length === 0 ? (
899
+ <p className="pt-2 text-xs text-muted-foreground">No events</p>
900
+ ) : (
901
+ items.map((event) => (
902
+ <button
903
+ key={occurrenceKey(event)}
904
+ type="button"
905
+ data-scheduler-day={isoDay(day)}
906
+ className="flex w-full min-w-0 items-start gap-2 rounded-md px-2 py-1.5 text-left hover:bg-muted/40"
907
+ style={eventColorStyle(event.color, "wash")}
908
+ onClick={() => onEventClick(event)}
909
+ >
910
+ <span className="mt-1.5 size-2 shrink-0 rounded-full" style={eventColorStyle(event.color, "dot")} />
911
+ <span className="min-w-0">
912
+ <span className="block truncate text-sm font-semibold text-foreground">{event.title}</span>
913
+ <span className="text-[11px] text-muted-foreground">
914
+ {event.allDay || isMonthBar(event)
915
+ ? "All day"
916
+ : `${formatTime(event.start, locale, hour12)} – ${formatTime(event.end, locale, hour12)}`}
917
+ </span>
918
+ </span>
919
+ </button>
920
+ ))
921
+ )}
922
+ </div>
923
+ </div>
924
+ );
925
+ })}
926
+ </div>
927
+ );
928
+ }