shadcn-rails 0.1.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 (315) hide show
  1. checksums.yaml +7 -0
  2. data/.dockerignore +40 -0
  3. data/CHANGELOG.md +54 -0
  4. data/CLAUDE.md +463 -0
  5. data/PROGRESS.md +485 -0
  6. data/README.md +1483 -0
  7. data/Rakefile +29 -0
  8. data/__tests__/controllers/__snapshots__/calendar_controller.test.js.snap +13 -0
  9. data/__tests__/controllers/__snapshots__/popover_controller.test.js.snap +46 -0
  10. data/__tests__/controllers/__snapshots__/sheet_controller.test.js.snap +111 -0
  11. data/__tests__/controllers/__snapshots__/tabs_controller.test.js.snap +27 -0
  12. data/__tests__/controllers/accordion_controller.test.js +904 -0
  13. data/__tests__/controllers/calendar_controller.test.js +1370 -0
  14. data/__tests__/controllers/carousel_controller.test.js +912 -0
  15. data/__tests__/controllers/checkbox_controller.test.js +454 -0
  16. data/__tests__/controllers/collapsible_controller.test.js +407 -0
  17. data/__tests__/controllers/combobox_controller.test.js +966 -0
  18. data/__tests__/controllers/context_menu_controller.test.js +627 -0
  19. data/__tests__/controllers/date_picker_controller.test.js +636 -0
  20. data/__tests__/controllers/dialog_controller.test.js +878 -0
  21. data/__tests__/controllers/drawer_controller.test.js +995 -0
  22. data/__tests__/controllers/menubar_controller.test.js +736 -0
  23. data/__tests__/controllers/navigation_menu_controller.test.js +598 -0
  24. data/__tests__/controllers/popover_controller.test.js +1007 -0
  25. data/__tests__/controllers/radio_group_controller.test.js +640 -0
  26. data/__tests__/controllers/resizable_controller.test.js +680 -0
  27. data/__tests__/controllers/select_controller.test.js +674 -0
  28. data/__tests__/controllers/sheet_controller.test.js +986 -0
  29. data/__tests__/controllers/slider_controller.test.js +1036 -0
  30. data/__tests__/controllers/switch_controller.test.js +424 -0
  31. data/__tests__/controllers/tabs_controller.test.js +907 -0
  32. data/__tests__/controllers/toggle_group_controller.test.js +839 -0
  33. data/__tests__/controllers/tooltip_controller.test.js +808 -0
  34. data/__tests__/helpers/stimulus-test-helper.js +203 -0
  35. data/app/assets/config/manifest.js +1 -0
  36. data/app/assets/javascripts/shadcn/controllers/accordion_controller.d.ts +53 -0
  37. data/app/assets/javascripts/shadcn/controllers/accordion_controller.js +140 -0
  38. data/app/assets/javascripts/shadcn/controllers/avatar_controller.d.ts +22 -0
  39. data/app/assets/javascripts/shadcn/controllers/avatar_controller.js +26 -0
  40. data/app/assets/javascripts/shadcn/controllers/calendar_controller.js +592 -0
  41. data/app/assets/javascripts/shadcn/controllers/carousel_controller.js +263 -0
  42. data/app/assets/javascripts/shadcn/controllers/checkbox_controller.d.ts +31 -0
  43. data/app/assets/javascripts/shadcn/controllers/checkbox_controller.js +48 -0
  44. data/app/assets/javascripts/shadcn/controllers/collapsible_controller.d.ts +43 -0
  45. data/app/assets/javascripts/shadcn/controllers/collapsible_controller.js +73 -0
  46. data/app/assets/javascripts/shadcn/controllers/combobox_controller.js +234 -0
  47. data/app/assets/javascripts/shadcn/controllers/command_controller.js +141 -0
  48. data/app/assets/javascripts/shadcn/controllers/command_dialog_controller.js +162 -0
  49. data/app/assets/javascripts/shadcn/controllers/context_menu_controller.js +202 -0
  50. data/app/assets/javascripts/shadcn/controllers/date_picker_controller.js +282 -0
  51. data/app/assets/javascripts/shadcn/controllers/dialog_controller.d.ts +67 -0
  52. data/app/assets/javascripts/shadcn/controllers/dialog_controller.js +187 -0
  53. data/app/assets/javascripts/shadcn/controllers/drawer_controller.d.ts +58 -0
  54. data/app/assets/javascripts/shadcn/controllers/drawer_controller.js +112 -0
  55. data/app/assets/javascripts/shadcn/controllers/dropdown_controller.d.ts +83 -0
  56. data/app/assets/javascripts/shadcn/controllers/dropdown_controller.js +225 -0
  57. data/app/assets/javascripts/shadcn/controllers/hover_card_controller.d.ts +59 -0
  58. data/app/assets/javascripts/shadcn/controllers/hover_card_controller.js +143 -0
  59. data/app/assets/javascripts/shadcn/controllers/input_otp_controller.d.ts +44 -0
  60. data/app/assets/javascripts/shadcn/controllers/input_otp_controller.js +206 -0
  61. data/app/assets/javascripts/shadcn/controllers/menubar_controller.js +323 -0
  62. data/app/assets/javascripts/shadcn/controllers/navigation_menu_controller.js +251 -0
  63. data/app/assets/javascripts/shadcn/controllers/popover_controller.d.ts +56 -0
  64. data/app/assets/javascripts/shadcn/controllers/popover_controller.js +141 -0
  65. data/app/assets/javascripts/shadcn/controllers/radio_group_controller.d.ts +47 -0
  66. data/app/assets/javascripts/shadcn/controllers/radio_group_controller.js +108 -0
  67. data/app/assets/javascripts/shadcn/controllers/resizable_controller.js +272 -0
  68. data/app/assets/javascripts/shadcn/controllers/scroll_area_controller.d.ts +44 -0
  69. data/app/assets/javascripts/shadcn/controllers/scroll_area_controller.js +74 -0
  70. data/app/assets/javascripts/shadcn/controllers/select_controller.d.ts +84 -0
  71. data/app/assets/javascripts/shadcn/controllers/select_controller.js +222 -0
  72. data/app/assets/javascripts/shadcn/controllers/sheet_controller.d.ts +60 -0
  73. data/app/assets/javascripts/shadcn/controllers/sheet_controller.js +151 -0
  74. data/app/assets/javascripts/shadcn/controllers/sidebar_controller.js +148 -0
  75. data/app/assets/javascripts/shadcn/controllers/slider_controller.d.ts +102 -0
  76. data/app/assets/javascripts/shadcn/controllers/slider_controller.js +364 -0
  77. data/app/assets/javascripts/shadcn/controllers/switch_controller.d.ts +46 -0
  78. data/app/assets/javascripts/shadcn/controllers/switch_controller.js +78 -0
  79. data/app/assets/javascripts/shadcn/controllers/tabs_controller.d.ts +51 -0
  80. data/app/assets/javascripts/shadcn/controllers/tabs_controller.js +126 -0
  81. data/app/assets/javascripts/shadcn/controllers/toast_controller.d.ts +37 -0
  82. data/app/assets/javascripts/shadcn/controllers/toast_controller.js +58 -0
  83. data/app/assets/javascripts/shadcn/controllers/toggle_controller.d.ts +27 -0
  84. data/app/assets/javascripts/shadcn/controllers/toggle_controller.js +42 -0
  85. data/app/assets/javascripts/shadcn/controllers/toggle_group_controller.d.ts +44 -0
  86. data/app/assets/javascripts/shadcn/controllers/toggle_group_controller.js +68 -0
  87. data/app/assets/javascripts/shadcn/controllers/tooltip_controller.d.ts +56 -0
  88. data/app/assets/javascripts/shadcn/controllers/tooltip_controller.js +117 -0
  89. data/app/assets/javascripts/shadcn/index.d.ts +74 -0
  90. data/app/assets/javascripts/shadcn/index.js +133 -0
  91. data/app/assets/stylesheets/.keep +0 -0
  92. data/app/assets/stylesheets/shadcn/base.css +445 -0
  93. data/app/assets/stylesheets/shadcn/components.css +513 -0
  94. data/app/assets/stylesheets/shadcn/index.css +18 -0
  95. data/app/assets/stylesheets/shadcn/themes/gray.css +68 -0
  96. data/app/assets/stylesheets/shadcn/themes/slate.css +68 -0
  97. data/app/assets/stylesheets/shadcn/themes/stone.css +68 -0
  98. data/app/assets/stylesheets/shadcn/themes/zinc.css +68 -0
  99. data/app/components/shadcn/accordion_component.rb +63 -0
  100. data/app/components/shadcn/accordion_content_component.rb +29 -0
  101. data/app/components/shadcn/accordion_item_component.rb +40 -0
  102. data/app/components/shadcn/accordion_trigger_component.rb +49 -0
  103. data/app/components/shadcn/alert_component.rb +75 -0
  104. data/app/components/shadcn/alert_description_component.rb +12 -0
  105. data/app/components/shadcn/alert_dialog_action_component.rb +24 -0
  106. data/app/components/shadcn/alert_dialog_cancel_component.rb +24 -0
  107. data/app/components/shadcn/alert_dialog_component.rb +71 -0
  108. data/app/components/shadcn/alert_dialog_content_component.rb +57 -0
  109. data/app/components/shadcn/alert_dialog_description_component.rb +12 -0
  110. data/app/components/shadcn/alert_dialog_footer_component.rb +19 -0
  111. data/app/components/shadcn/alert_dialog_header_component.rb +19 -0
  112. data/app/components/shadcn/alert_dialog_title_component.rb +12 -0
  113. data/app/components/shadcn/alert_title_component.rb +12 -0
  114. data/app/components/shadcn/aspect_ratio_component.rb +49 -0
  115. data/app/components/shadcn/avatar_component.rb +107 -0
  116. data/app/components/shadcn/avatar_fallback_component.rb +17 -0
  117. data/app/components/shadcn/badge_component.rb +49 -0
  118. data/app/components/shadcn/base_component.rb +100 -0
  119. data/app/components/shadcn/breadcrumb_component.rb +70 -0
  120. data/app/components/shadcn/breadcrumb_item_component.rb +50 -0
  121. data/app/components/shadcn/button_component.rb +141 -0
  122. data/app/components/shadcn/button_group_component.rb +69 -0
  123. data/app/components/shadcn/calendar_component.rb +337 -0
  124. data/app/components/shadcn/card_action_component.rb +10 -0
  125. data/app/components/shadcn/card_component.rb +63 -0
  126. data/app/components/shadcn/card_content_component.rb +19 -0
  127. data/app/components/shadcn/card_description_component.rb +12 -0
  128. data/app/components/shadcn/card_footer_component.rb +12 -0
  129. data/app/components/shadcn/card_header_component.rb +24 -0
  130. data/app/components/shadcn/card_title_component.rb +18 -0
  131. data/app/components/shadcn/carousel_component.rb +275 -0
  132. data/app/components/shadcn/checkbox_component.rb +103 -0
  133. data/app/components/shadcn/collapsible_component.rb +66 -0
  134. data/app/components/shadcn/collapsible_content_component.rb +28 -0
  135. data/app/components/shadcn/combobox_component.rb +322 -0
  136. data/app/components/shadcn/command_component.rb +52 -0
  137. data/app/components/shadcn/command_dialog_component.rb +76 -0
  138. data/app/components/shadcn/command_empty_component.rb +12 -0
  139. data/app/components/shadcn/command_group_component.rb +34 -0
  140. data/app/components/shadcn/command_input_component.rb +59 -0
  141. data/app/components/shadcn/command_item_component.rb +48 -0
  142. data/app/components/shadcn/command_list_component.rb +38 -0
  143. data/app/components/shadcn/command_separator_component.rb +12 -0
  144. data/app/components/shadcn/command_shortcut_component.rb +12 -0
  145. data/app/components/shadcn/context_menu_component.rb +64 -0
  146. data/app/components/shadcn/context_menu_content_component.rb +44 -0
  147. data/app/components/shadcn/context_menu_item_component.rb +63 -0
  148. data/app/components/shadcn/context_menu_label_component.rb +18 -0
  149. data/app/components/shadcn/context_menu_separator_component.rb +12 -0
  150. data/app/components/shadcn/context_menu_shortcut_component.rb +12 -0
  151. data/app/components/shadcn/date_picker_component.rb +368 -0
  152. data/app/components/shadcn/dialog_component.rb +77 -0
  153. data/app/components/shadcn/dialog_content_component.rb +91 -0
  154. data/app/components/shadcn/dialog_description_component.rb +12 -0
  155. data/app/components/shadcn/dialog_footer_component.rb +12 -0
  156. data/app/components/shadcn/dialog_header_component.rb +19 -0
  157. data/app/components/shadcn/dialog_title_component.rb +12 -0
  158. data/app/components/shadcn/drawer_component.rb +72 -0
  159. data/app/components/shadcn/drawer_content_component.rb +76 -0
  160. data/app/components/shadcn/drawer_description_component.rb +12 -0
  161. data/app/components/shadcn/drawer_footer_component.rb +12 -0
  162. data/app/components/shadcn/drawer_header_component.rb +19 -0
  163. data/app/components/shadcn/drawer_title_component.rb +12 -0
  164. data/app/components/shadcn/dropdown_menu_component.rb +75 -0
  165. data/app/components/shadcn/dropdown_menu_content_component.rb +49 -0
  166. data/app/components/shadcn/dropdown_menu_group_component.rb +10 -0
  167. data/app/components/shadcn/dropdown_menu_item_component.rb +63 -0
  168. data/app/components/shadcn/dropdown_menu_label_component.rb +18 -0
  169. data/app/components/shadcn/dropdown_menu_separator_component.rb +12 -0
  170. data/app/components/shadcn/dropdown_menu_shortcut_component.rb +12 -0
  171. data/app/components/shadcn/empty_component.rb +48 -0
  172. data/app/components/shadcn/empty_content_component.rb +12 -0
  173. data/app/components/shadcn/empty_description_component.rb +12 -0
  174. data/app/components/shadcn/empty_header_component.rb +29 -0
  175. data/app/components/shadcn/empty_media_component.rb +21 -0
  176. data/app/components/shadcn/empty_title_component.rb +12 -0
  177. data/app/components/shadcn/field_component.rb +113 -0
  178. data/app/components/shadcn/hover_card_component.rb +64 -0
  179. data/app/components/shadcn/hover_card_content_component.rb +36 -0
  180. data/app/components/shadcn/input_component.rb +108 -0
  181. data/app/components/shadcn/input_group_component.rb +70 -0
  182. data/app/components/shadcn/input_otp_component.rb +183 -0
  183. data/app/components/shadcn/item_actions_component.rb +12 -0
  184. data/app/components/shadcn/item_component.rb +98 -0
  185. data/app/components/shadcn/item_content_component.rb +24 -0
  186. data/app/components/shadcn/item_description_component.rb +12 -0
  187. data/app/components/shadcn/item_footer_component.rb +12 -0
  188. data/app/components/shadcn/item_group_component.rb +24 -0
  189. data/app/components/shadcn/item_header_component.rb +12 -0
  190. data/app/components/shadcn/item_media_component.rb +22 -0
  191. data/app/components/shadcn/item_separator_component.rb +12 -0
  192. data/app/components/shadcn/item_title_component.rb +12 -0
  193. data/app/components/shadcn/kbd_component.rb +36 -0
  194. data/app/components/shadcn/label_component.rb +49 -0
  195. data/app/components/shadcn/menubar_checkbox_item_component.rb +76 -0
  196. data/app/components/shadcn/menubar_component.rb +56 -0
  197. data/app/components/shadcn/menubar_content_component.rb +64 -0
  198. data/app/components/shadcn/menubar_item_component.rb +65 -0
  199. data/app/components/shadcn/menubar_label_component.rb +27 -0
  200. data/app/components/shadcn/menubar_menu_component.rb +34 -0
  201. data/app/components/shadcn/menubar_radio_group_component.rb +42 -0
  202. data/app/components/shadcn/menubar_radio_item_component.rb +76 -0
  203. data/app/components/shadcn/menubar_separator_component.rb +22 -0
  204. data/app/components/shadcn/menubar_shortcut_component.rb +21 -0
  205. data/app/components/shadcn/menubar_sub_component.rb +38 -0
  206. data/app/components/shadcn/menubar_sub_content_component.rb +45 -0
  207. data/app/components/shadcn/menubar_sub_trigger_component.rb +59 -0
  208. data/app/components/shadcn/menubar_trigger_component.rb +31 -0
  209. data/app/components/shadcn/native_select_component.rb +150 -0
  210. data/app/components/shadcn/navigation_menu_component.rb +76 -0
  211. data/app/components/shadcn/navigation_menu_content_component.rb +30 -0
  212. data/app/components/shadcn/navigation_menu_item_component.rb +39 -0
  213. data/app/components/shadcn/navigation_menu_link_component.rb +38 -0
  214. data/app/components/shadcn/navigation_menu_list_component.rb +29 -0
  215. data/app/components/shadcn/navigation_menu_trigger_component.rb +59 -0
  216. data/app/components/shadcn/pagination_component.rb +195 -0
  217. data/app/components/shadcn/pagination_content_component.rb +47 -0
  218. data/app/components/shadcn/pagination_ellipsis_component.rb +30 -0
  219. data/app/components/shadcn/pagination_item_component.rb +53 -0
  220. data/app/components/shadcn/pagination_next_component.rb +48 -0
  221. data/app/components/shadcn/pagination_previous_component.rb +48 -0
  222. data/app/components/shadcn/popover_component.rb +76 -0
  223. data/app/components/shadcn/popover_content_component.rb +25 -0
  224. data/app/components/shadcn/progress_component.rb +77 -0
  225. data/app/components/shadcn/radio_group_component.rb +129 -0
  226. data/app/components/shadcn/radio_group_item_component.rb +109 -0
  227. data/app/components/shadcn/resizable_handle_component.rb +98 -0
  228. data/app/components/shadcn/resizable_panel_component.rb +56 -0
  229. data/app/components/shadcn/resizable_panel_group_component.rb +94 -0
  230. data/app/components/shadcn/scroll_area_component.rb +110 -0
  231. data/app/components/shadcn/select_component.rb +151 -0
  232. data/app/components/shadcn/select_group_component.rb +32 -0
  233. data/app/components/shadcn/select_item_component.rb +59 -0
  234. data/app/components/shadcn/select_separator_component.rb +12 -0
  235. data/app/components/shadcn/separator_component.rb +54 -0
  236. data/app/components/shadcn/sheet_component.rb +82 -0
  237. data/app/components/shadcn/sheet_content_component.rb +95 -0
  238. data/app/components/shadcn/sheet_description_component.rb +12 -0
  239. data/app/components/shadcn/sheet_footer_component.rb +12 -0
  240. data/app/components/shadcn/sheet_header_component.rb +19 -0
  241. data/app/components/shadcn/sheet_title_component.rb +12 -0
  242. data/app/components/shadcn/sidebar_component.rb +180 -0
  243. data/app/components/shadcn/sidebar_content_component.rb +32 -0
  244. data/app/components/shadcn/sidebar_footer_component.rb +24 -0
  245. data/app/components/shadcn/sidebar_group_action_component.rb +26 -0
  246. data/app/components/shadcn/sidebar_group_component.rb +38 -0
  247. data/app/components/shadcn/sidebar_group_content_component.rb +32 -0
  248. data/app/components/shadcn/sidebar_group_label_component.rb +25 -0
  249. data/app/components/shadcn/sidebar_header_component.rb +24 -0
  250. data/app/components/shadcn/sidebar_inset_component.rb +25 -0
  251. data/app/components/shadcn/sidebar_menu_action_component.rb +37 -0
  252. data/app/components/shadcn/sidebar_menu_badge_component.rb +25 -0
  253. data/app/components/shadcn/sidebar_menu_button_component.rb +52 -0
  254. data/app/components/shadcn/sidebar_menu_component.rb +32 -0
  255. data/app/components/shadcn/sidebar_menu_item_component.rb +41 -0
  256. data/app/components/shadcn/sidebar_menu_skeleton_component.rb +46 -0
  257. data/app/components/shadcn/sidebar_menu_sub_button_component.rb +43 -0
  258. data/app/components/shadcn/sidebar_menu_sub_component.rb +33 -0
  259. data/app/components/shadcn/sidebar_menu_sub_item_component.rb +30 -0
  260. data/app/components/shadcn/sidebar_provider_component.rb +57 -0
  261. data/app/components/shadcn/sidebar_rail_component.rb +30 -0
  262. data/app/components/shadcn/sidebar_separator_component.rb +24 -0
  263. data/app/components/shadcn/sidebar_trigger_component.rb +51 -0
  264. data/app/components/shadcn/skeleton_component.rb +29 -0
  265. data/app/components/shadcn/slider_component.rb +76 -0
  266. data/app/components/shadcn/spinner_component.rb +67 -0
  267. data/app/components/shadcn/switch_component.rb +147 -0
  268. data/app/components/shadcn/table_body_component.rb +16 -0
  269. data/app/components/shadcn/table_caption_component.rb +12 -0
  270. data/app/components/shadcn/table_cell_component.rb +12 -0
  271. data/app/components/shadcn/table_component.rb +57 -0
  272. data/app/components/shadcn/table_footer_component.rb +16 -0
  273. data/app/components/shadcn/table_head_component.rb +12 -0
  274. data/app/components/shadcn/table_header_component.rb +16 -0
  275. data/app/components/shadcn/table_row_component.rb +40 -0
  276. data/app/components/shadcn/tabs_component.rb +78 -0
  277. data/app/components/shadcn/tabs_content_component.rb +32 -0
  278. data/app/components/shadcn/tabs_list_component.rb +30 -0
  279. data/app/components/shadcn/tabs_trigger_component.rb +37 -0
  280. data/app/components/shadcn/textarea_component.rb +84 -0
  281. data/app/components/shadcn/toast_action_component.rb +18 -0
  282. data/app/components/shadcn/toast_component.rb +114 -0
  283. data/app/components/shadcn/toast_description_component.rb +12 -0
  284. data/app/components/shadcn/toast_title_component.rb +12 -0
  285. data/app/components/shadcn/toast_viewport_component.rb +12 -0
  286. data/app/components/shadcn/toggle_component.rb +77 -0
  287. data/app/components/shadcn/toggle_group_component.rb +96 -0
  288. data/app/components/shadcn/toggle_group_item_component.rb +62 -0
  289. data/app/components/shadcn/tooltip_component.rb +89 -0
  290. data/app/components/shadcn/typography_component.rb +112 -0
  291. data/babel.config.cjs +5 -0
  292. data/bin/console +11 -0
  293. data/bin/setup +8 -0
  294. data/config/importmap.rb +5 -0
  295. data/fly.toml +26 -0
  296. data/jest.config.js +19 -0
  297. data/jest.setup.js +8 -0
  298. data/lib/generators/shadcn/component/component_generator.rb +188 -0
  299. data/lib/generators/shadcn/install/install_generator.rb +140 -0
  300. data/lib/generators/shadcn/install/templates/initializer.rb.tt +35 -0
  301. data/lib/generators/shadcn/install/templates/shadcn.yml.tt +35 -0
  302. data/lib/generators/shadcn/theme/theme_generator.rb +128 -0
  303. data/lib/shadcn/rails/class_merger.rb +228 -0
  304. data/lib/shadcn/rails/configuration.rb +341 -0
  305. data/lib/shadcn/rails/engine.rb +59 -0
  306. data/lib/shadcn/rails/helpers/class_name_helper.rb +35 -0
  307. data/lib/shadcn/rails/helpers/component_helper.rb +60 -0
  308. data/lib/shadcn/rails/helpers/pagination_helper.rb +187 -0
  309. data/lib/shadcn/rails/version.rb +7 -0
  310. data/lib/shadcn/rails.rb +179 -0
  311. data/package-lock.json +7415 -0
  312. data/package.json +68 -0
  313. data/rollup.config.js +29 -0
  314. data/sig/shadcn/rails.rbs +6 -0
  315. metadata +526 -0
@@ -0,0 +1,1370 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+ import CalendarController from "../../app/assets/javascripts/shadcn/controllers/calendar_controller.js"
3
+
4
+ describe("CalendarController", () => {
5
+ let application
6
+ let element
7
+ let controller
8
+
9
+ const calendarHTML = `
10
+ <div data-controller="calendar"
11
+ data-calendar-month-value="2024-11-01"
12
+ data-calendar-selected-value="">
13
+ <div data-calendar-target="monthYear"></div>
14
+ <select data-calendar-target="monthSelect"></select>
15
+ <select data-calendar-target="yearSelect"></select>
16
+ <div data-calendar-target="grid"></div>
17
+ <input type="hidden" data-calendar-target="hiddenInput">
18
+ </div>
19
+ `
20
+
21
+ beforeEach(async () => {
22
+ application = Application.start()
23
+ application.register("calendar", CalendarController)
24
+ document.body.innerHTML = calendarHTML
25
+
26
+ await new Promise(resolve => requestAnimationFrame(resolve))
27
+
28
+ element = document.querySelector('[data-controller="calendar"]')
29
+ controller = application.getControllerForElementAndIdentifier(element, "calendar")
30
+ })
31
+
32
+ afterEach(() => {
33
+ if (application) {
34
+ application.stop()
35
+ }
36
+ document.body.innerHTML = ""
37
+ })
38
+
39
+ describe("parseLocalDate", () => {
40
+ test("parses date string as local date, not UTC", () => {
41
+ const date = controller.parseLocalDate("2024-11-26")
42
+
43
+ expect(date.getFullYear()).toBe(2024)
44
+ expect(date.getMonth()).toBe(10) // November is month 10 (0-indexed)
45
+ expect(date.getDate()).toBe(26)
46
+ })
47
+
48
+ test("returns null for empty string", () => {
49
+ expect(controller.parseLocalDate("")).toBeNull()
50
+ })
51
+
52
+ test("returns null for null input", () => {
53
+ expect(controller.parseLocalDate(null)).toBeNull()
54
+ })
55
+
56
+ test("handles first day of month", () => {
57
+ const date = controller.parseLocalDate("2024-01-01")
58
+
59
+ expect(date.getFullYear()).toBe(2024)
60
+ expect(date.getMonth()).toBe(0) // January
61
+ expect(date.getDate()).toBe(1)
62
+ })
63
+
64
+ test("handles last day of month", () => {
65
+ const date = controller.parseLocalDate("2024-12-31")
66
+
67
+ expect(date.getFullYear()).toBe(2024)
68
+ expect(date.getMonth()).toBe(11) // December
69
+ expect(date.getDate()).toBe(31)
70
+ })
71
+
72
+ test("handles leap year February 29", () => {
73
+ const date = controller.parseLocalDate("2024-02-29")
74
+
75
+ expect(date.getFullYear()).toBe(2024)
76
+ expect(date.getMonth()).toBe(1) // February
77
+ expect(date.getDate()).toBe(29)
78
+ })
79
+ })
80
+
81
+ describe("formatDateString", () => {
82
+ test("formats date as YYYY-MM-DD", () => {
83
+ const date = new Date(2024, 10, 26) // November 26, 2024
84
+ expect(controller.formatDateString(date)).toBe("2024-11-26")
85
+ })
86
+
87
+ test("pads single digit months", () => {
88
+ const date = new Date(2024, 0, 15) // January 15, 2024
89
+ expect(controller.formatDateString(date)).toBe("2024-01-15")
90
+ })
91
+
92
+ test("pads single digit days", () => {
93
+ const date = new Date(2024, 10, 5) // November 5, 2024
94
+ expect(controller.formatDateString(date)).toBe("2024-11-05")
95
+ })
96
+
97
+ test("returns empty string for null", () => {
98
+ expect(controller.formatDateString(null)).toBe("")
99
+ })
100
+ })
101
+
102
+ describe("connect", () => {
103
+ test("initializes currentMonth from monthValue", () => {
104
+ expect(controller.currentMonth.getFullYear()).toBe(2024)
105
+ expect(controller.currentMonth.getMonth()).toBe(10) // November
106
+ })
107
+
108
+ test("initializes selectedDate as null when no selectedValue", () => {
109
+ expect(controller.selectedDate).toBeNull()
110
+ })
111
+ })
112
+
113
+ describe("previousMonth", () => {
114
+ test("moves to the previous month", () => {
115
+ controller.previousMonth()
116
+
117
+ expect(controller.currentMonth.getMonth()).toBe(9) // October
118
+ })
119
+
120
+ test("wraps to previous year from January", () => {
121
+ controller.currentMonth = new Date(2024, 0, 1) // January 2024
122
+ controller.previousMonth()
123
+
124
+ expect(controller.currentMonth.getMonth()).toBe(11) // December
125
+ expect(controller.currentMonth.getFullYear()).toBe(2023)
126
+ })
127
+ })
128
+
129
+ describe("nextMonth", () => {
130
+ test("moves to the next month", () => {
131
+ controller.nextMonth()
132
+
133
+ expect(controller.currentMonth.getMonth()).toBe(11) // December
134
+ })
135
+
136
+ test("wraps to next year from December", () => {
137
+ controller.currentMonth = new Date(2024, 11, 1) // December 2024
138
+ controller.nextMonth()
139
+
140
+ expect(controller.currentMonth.getMonth()).toBe(0) // January
141
+ expect(controller.currentMonth.getFullYear()).toBe(2025)
142
+ })
143
+ })
144
+
145
+ describe("selectDay", () => {
146
+ test("selects the clicked date", () => {
147
+ // Create a mock event with the date data
148
+ const mockEvent = {
149
+ currentTarget: {
150
+ dataset: { date: "2024-11-15" }
151
+ }
152
+ }
153
+
154
+ controller.selectDay(mockEvent)
155
+
156
+ expect(controller.selectedDate).not.toBeNull()
157
+ expect(controller.selectedDate.getDate()).toBe(15)
158
+ expect(controller.selectedDate.getMonth()).toBe(10) // November
159
+ expect(controller.selectedDate.getFullYear()).toBe(2024)
160
+ })
161
+
162
+ test("updates the hidden input value", () => {
163
+ const mockEvent = {
164
+ currentTarget: {
165
+ dataset: { date: "2024-11-20" }
166
+ }
167
+ }
168
+
169
+ controller.selectDay(mockEvent)
170
+
171
+ const hiddenInput = element.querySelector('[data-calendar-target="hiddenInput"]')
172
+ expect(hiddenInput.value).toBe("2024-11-20")
173
+ })
174
+
175
+ test("dispatches select event with date details", () => {
176
+ let eventDetail = null
177
+ element.addEventListener("calendar:select", (e) => {
178
+ eventDetail = e.detail
179
+ })
180
+
181
+ const mockEvent = {
182
+ currentTarget: {
183
+ dataset: { date: "2024-11-10" }
184
+ }
185
+ }
186
+
187
+ controller.selectDay(mockEvent)
188
+
189
+ expect(eventDetail).not.toBeNull()
190
+ expect(eventDetail.dateString).toBe("2024-11-10")
191
+ expect(eventDetail.date.getDate()).toBe(10)
192
+ })
193
+
194
+ test("does nothing if no date in event", () => {
195
+ const mockEvent = {
196
+ currentTarget: {
197
+ dataset: {}
198
+ }
199
+ }
200
+
201
+ controller.selectDay(mockEvent)
202
+
203
+ expect(controller.selectedDate).toBeNull()
204
+ })
205
+ })
206
+
207
+ describe("render", () => {
208
+ test("updates month/year display", () => {
209
+ controller.render()
210
+
211
+ const monthYearDisplay = element.querySelector('[data-calendar-target="monthYear"]')
212
+ expect(monthYearDisplay.textContent).toBe("November 2024")
213
+ })
214
+
215
+ test("renders correct number of day buttons (6 weeks = 42 days)", () => {
216
+ controller.render()
217
+
218
+ // Use a more flexible selector since targets are set via data attributes
219
+ const grid = element.querySelector('[data-calendar-target="grid"]')
220
+ const dayButtons = grid.querySelectorAll('button[data-date]')
221
+ // Should be between 28 and 42 days depending on month layout
222
+ expect(dayButtons.length).toBeGreaterThanOrEqual(28)
223
+ expect(dayButtons.length).toBeLessThanOrEqual(42)
224
+ })
225
+
226
+ test("marks today with special styling", () => {
227
+ // Set current month to today's month
228
+ const today = new Date()
229
+ controller.currentMonth = new Date(today.getFullYear(), today.getMonth(), 1)
230
+ controller.render()
231
+
232
+ const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
233
+ const todayButton = element.querySelector(`[data-date="${todayStr}"]`)
234
+
235
+ expect(todayButton).not.toBeNull()
236
+ expect(todayButton.classList.contains("bg-accent")).toBe(true)
237
+ })
238
+
239
+ test("marks selected date with primary styling", () => {
240
+ controller.selectedDate = new Date(2024, 10, 15)
241
+ controller.selectedValue = "2024-11-15"
242
+ controller.render()
243
+
244
+ const selectedButton = element.querySelector('[data-date="2024-11-15"]')
245
+ expect(selectedButton.classList.contains("bg-primary")).toBe(true)
246
+ expect(selectedButton.getAttribute("aria-selected")).toBe("true")
247
+ })
248
+
249
+ test("marks outside month dates with muted styling", () => {
250
+ controller.render()
251
+
252
+ // November 2024 starts on Friday, so there should be days from October
253
+ const grid = element.querySelector('[data-calendar-target="grid"]')
254
+ const allDays = grid.querySelectorAll("button[data-date]")
255
+ const outsideDays = Array.from(allDays).filter(btn => {
256
+ const dateStr = btn.dataset.date
257
+ if (!dateStr) return false
258
+ const month = parseInt(dateStr.split('-')[1], 10)
259
+ return month !== 11 // Not November
260
+ })
261
+
262
+ // Check at least some outside days exist and have the styling
263
+ if (outsideDays.length > 0) {
264
+ expect(outsideDays[0].classList.contains("text-muted-foreground")).toBe(true)
265
+ expect(outsideDays[0].classList.contains("opacity-50")).toBe(true)
266
+ }
267
+ })
268
+ })
269
+
270
+ describe("timezone handling", () => {
271
+ test("selecting a date preserves the correct day regardless of timezone", () => {
272
+ // This is the critical test for the timezone bug
273
+ // When parsing "2024-11-26" via new Date("2024-11-26"), it interprets
274
+ // as UTC midnight, which becomes Nov 25 in western timezones
275
+
276
+ const mockEvent = {
277
+ currentTarget: {
278
+ dataset: { date: "2024-11-15" }
279
+ }
280
+ }
281
+
282
+ controller.selectDay(mockEvent)
283
+
284
+ // The selected date should be exactly November 15, not November 14
285
+ expect(controller.selectedDate.getDate()).toBe(15)
286
+ expect(controller.selectedDate.getMonth()).toBe(10) // November
287
+ expect(controller.selectedValue).toBe("2024-11-15")
288
+ })
289
+
290
+ test("initializing with a selected value preserves the correct day", async () => {
291
+ // Test that selectedValue initialization doesn't shift the date
292
+ application.stop()
293
+ document.body.innerHTML = ""
294
+
295
+ document.body.innerHTML = `
296
+ <div data-controller="calendar"
297
+ data-calendar-month-value="2024-11-01"
298
+ data-calendar-selected-value="2024-11-26">
299
+ <div data-calendar-target="grid"></div>
300
+ </div>
301
+ `
302
+
303
+ application = Application.start()
304
+ application.register("calendar", CalendarController)
305
+
306
+ await new Promise(resolve => requestAnimationFrame(resolve))
307
+
308
+ const newElement = document.querySelector('[data-controller="calendar"]')
309
+ const newController = application.getControllerForElementAndIdentifier(newElement, "calendar")
310
+
311
+ expect(newController.selectedDate.getDate()).toBe(26)
312
+ expect(newController.selectedDate.getMonth()).toBe(10) // November
313
+ })
314
+
315
+ test("parseLocalDate avoids UTC timezone shift for any date", () => {
316
+ // Test a variety of dates that could be affected by timezone
317
+ const testDates = [
318
+ "2024-01-01", // New Year
319
+ "2024-06-15", // Mid-year
320
+ "2024-12-31", // End of year
321
+ "2024-03-10", // DST transition day (US)
322
+ "2024-11-03", // DST transition day (US)
323
+ ]
324
+
325
+ testDates.forEach(dateStr => {
326
+ const [year, month, day] = dateStr.split('-').map(Number)
327
+ const parsed = controller.parseLocalDate(dateStr)
328
+
329
+ expect(parsed.getFullYear()).toBe(year)
330
+ expect(parsed.getMonth()).toBe(month - 1)
331
+ expect(parsed.getDate()).toBe(day)
332
+ })
333
+ })
334
+ })
335
+
336
+ describe("monthValueChanged", () => {
337
+ test("updates currentMonth when value changes", () => {
338
+ controller.monthValue = "2024-06-01"
339
+ controller.monthValueChanged()
340
+
341
+ expect(controller.currentMonth.getMonth()).toBe(5) // June
342
+ expect(controller.currentMonth.getFullYear()).toBe(2024)
343
+ })
344
+ })
345
+
346
+ describe("selectedValueChanged", () => {
347
+ test("updates selectedDate when value changes", () => {
348
+ controller.selectedValue = "2024-07-20"
349
+ controller.selectedValueChanged()
350
+
351
+ expect(controller.selectedDate.getDate()).toBe(20)
352
+ expect(controller.selectedDate.getMonth()).toBe(6) // July
353
+ })
354
+ })
355
+
356
+ describe("MONTHS constant", () => {
357
+ test("contains all 12 months in order", () => {
358
+ expect(CalendarController.MONTHS).toEqual([
359
+ "January", "February", "March", "April", "May", "June",
360
+ "July", "August", "September", "October", "November", "December"
361
+ ])
362
+ })
363
+ })
364
+
365
+ describe("WEEKDAYS constant", () => {
366
+ test("contains all 7 weekdays starting with Sunday", () => {
367
+ expect(CalendarController.WEEKDAYS).toEqual([
368
+ "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"
369
+ ])
370
+ })
371
+ })
372
+
373
+ describe("isDateDisabled", () => {
374
+ test("returns false for dates within valid range", () => {
375
+ controller.minDateValue = "2024-11-01"
376
+ controller.maxDateValue = "2024-11-30"
377
+
378
+ const date = new Date(2024, 10, 15)
379
+ expect(controller.isDateDisabled(date)).toBe(false)
380
+ })
381
+
382
+ test("returns true for dates before minDate", () => {
383
+ controller.minDateValue = "2024-11-10"
384
+
385
+ const date = new Date(2024, 10, 5)
386
+ expect(controller.isDateDisabled(date)).toBe(true)
387
+ })
388
+
389
+ test("returns true for dates after maxDate", () => {
390
+ controller.maxDateValue = "2024-11-20"
391
+
392
+ const date = new Date(2024, 10, 25)
393
+ expect(controller.isDateDisabled(date)).toBe(true)
394
+ })
395
+
396
+ test("returns true for dates in disabledDates list", () => {
397
+ controller.disabledDatesValue = "2024-11-15,2024-11-16,2024-11-17"
398
+
399
+ expect(controller.isDateDisabled(new Date(2024, 10, 15))).toBe(true)
400
+ expect(controller.isDateDisabled(new Date(2024, 10, 18))).toBe(false)
401
+ })
402
+
403
+ test("returns true for disabled days of week", () => {
404
+ controller.disabledDaysOfWeekValue = "0,6" // Sunday and Saturday
405
+
406
+ // November 16, 2024 is a Saturday
407
+ expect(controller.isDateDisabled(new Date(2024, 10, 16))).toBe(true)
408
+ // November 17, 2024 is a Sunday
409
+ expect(controller.isDateDisabled(new Date(2024, 10, 17))).toBe(true)
410
+ // November 18, 2024 is a Monday
411
+ expect(controller.isDateDisabled(new Date(2024, 10, 18))).toBe(false)
412
+ })
413
+ })
414
+
415
+ describe("multiple selection mode", () => {
416
+ beforeEach(async () => {
417
+ application.stop()
418
+ document.body.innerHTML = ""
419
+
420
+ document.body.innerHTML = `
421
+ <div data-controller="calendar"
422
+ data-calendar-month-value="2024-11-01"
423
+ data-calendar-mode-value="multiple"
424
+ data-calendar-selected-value="">
425
+ <div data-calendar-target="grid"></div>
426
+ <input type="hidden" data-calendar-target="hiddenInput">
427
+ </div>
428
+ `
429
+
430
+ application = Application.start()
431
+ application.register("calendar", CalendarController)
432
+
433
+ await new Promise(resolve => requestAnimationFrame(resolve))
434
+
435
+ element = document.querySelector('[data-controller="calendar"]')
436
+ controller = application.getControllerForElementAndIdentifier(element, "calendar")
437
+ })
438
+
439
+ test("initializes with empty array", () => {
440
+ expect(controller.selectedDate).toEqual([])
441
+ })
442
+
443
+ test("can select multiple dates", () => {
444
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-10" } } })
445
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-15" } } })
446
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-20" } } })
447
+
448
+ expect(controller.selectedDate.length).toBe(3)
449
+ expect(controller.selectedValue).toBe("2024-11-10,2024-11-15,2024-11-20")
450
+ })
451
+
452
+ test("can deselect dates by clicking again", () => {
453
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-10" } } })
454
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-15" } } })
455
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-10" } } }) // deselect
456
+
457
+ expect(controller.selectedDate.length).toBe(1)
458
+ expect(controller.selectedValue).toBe("2024-11-15")
459
+ })
460
+
461
+ test("dispatches select event with dates array", () => {
462
+ let eventDetail = null
463
+ element.addEventListener("calendar:select", (e) => {
464
+ eventDetail = e.detail
465
+ })
466
+
467
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-10" } } })
468
+
469
+ expect(eventDetail.dates).toBeDefined()
470
+ expect(eventDetail.dateStrings).toContain("2024-11-10")
471
+ })
472
+ })
473
+
474
+ describe("range selection mode", () => {
475
+ beforeEach(async () => {
476
+ application.stop()
477
+ document.body.innerHTML = ""
478
+
479
+ document.body.innerHTML = `
480
+ <div data-controller="calendar"
481
+ data-calendar-month-value="2024-11-01"
482
+ data-calendar-mode-value="range"
483
+ data-calendar-selected-value="">
484
+ <div data-calendar-target="grid"></div>
485
+ <input type="hidden" data-calendar-target="hiddenInput">
486
+ </div>
487
+ `
488
+
489
+ application = Application.start()
490
+ application.register("calendar", CalendarController)
491
+
492
+ await new Promise(resolve => requestAnimationFrame(resolve))
493
+
494
+ element = document.querySelector('[data-controller="calendar"]')
495
+ controller = application.getControllerForElementAndIdentifier(element, "calendar")
496
+ })
497
+
498
+ test("first click sets range start", () => {
499
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-10" } } })
500
+
501
+ expect(controller.rangeStart).not.toBeNull()
502
+ expect(controller.rangeStart.getDate()).toBe(10)
503
+ expect(controller.rangeEnd).toBeNull()
504
+ })
505
+
506
+ test("second click sets range end", () => {
507
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-10" } } })
508
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-20" } } })
509
+
510
+ expect(controller.rangeStart.getDate()).toBe(10)
511
+ expect(controller.rangeEnd.getDate()).toBe(20)
512
+ expect(controller.selectedValue).toBe("2024-11-10,2024-11-20")
513
+ })
514
+
515
+ test("swaps start and end if end is before start", () => {
516
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-20" } } })
517
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-10" } } })
518
+
519
+ expect(controller.rangeStart.getDate()).toBe(10)
520
+ expect(controller.rangeEnd.getDate()).toBe(20)
521
+ })
522
+
523
+ test("third click starts new range", () => {
524
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-10" } } })
525
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-20" } } })
526
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-25" } } })
527
+
528
+ expect(controller.rangeStart.getDate()).toBe(25)
529
+ expect(controller.rangeEnd).toBeNull()
530
+ })
531
+
532
+ test("isDateInRange returns true for dates between start and end", () => {
533
+ controller.rangeStart = new Date(2024, 10, 10)
534
+ controller.rangeEnd = new Date(2024, 10, 20)
535
+
536
+ expect(controller.isDateInRange(new Date(2024, 10, 15))).toBe(true)
537
+ expect(controller.isDateInRange(new Date(2024, 10, 10))).toBe(false) // start
538
+ expect(controller.isDateInRange(new Date(2024, 10, 20))).toBe(false) // end
539
+ expect(controller.isDateInRange(new Date(2024, 10, 5))).toBe(false) // before
540
+ expect(controller.isDateInRange(new Date(2024, 10, 25))).toBe(false) // after
541
+ })
542
+ })
543
+
544
+ describe("required mode", () => {
545
+ beforeEach(async () => {
546
+ application.stop()
547
+ document.body.innerHTML = ""
548
+
549
+ document.body.innerHTML = `
550
+ <div data-controller="calendar"
551
+ data-calendar-month-value="2024-11-01"
552
+ data-calendar-required-value="true"
553
+ data-calendar-selected-value="2024-11-15">
554
+ <div data-calendar-target="grid"></div>
555
+ </div>
556
+ `
557
+
558
+ application = Application.start()
559
+ application.register("calendar", CalendarController)
560
+
561
+ await new Promise(resolve => requestAnimationFrame(resolve))
562
+
563
+ element = document.querySelector('[data-controller="calendar"]')
564
+ controller = application.getControllerForElementAndIdentifier(element, "calendar")
565
+ })
566
+
567
+ test("prevents deselection when required is true", () => {
568
+ expect(controller.selectedDate.getDate()).toBe(15)
569
+
570
+ // Try to deselect by clicking the same date
571
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-15" } } })
572
+
573
+ // Should still be selected
574
+ expect(controller.selectedDate.getDate()).toBe(15)
575
+ })
576
+
577
+ test("allows selecting a different date", () => {
578
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-20" } } })
579
+
580
+ expect(controller.selectedDate.getDate()).toBe(20)
581
+ })
582
+ })
583
+
584
+ describe("disabled date selection", () => {
585
+ test("does not select disabled dates", () => {
586
+ controller.disabledDatesValue = "2024-11-15"
587
+
588
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-15" } } })
589
+
590
+ expect(controller.selectedDate).toBeNull()
591
+ })
592
+ })
593
+
594
+ describe("disabled days CSS rendering", () => {
595
+ test("disabled dates have correct CSS classes", () => {
596
+ controller.disabledDatesValue = "2024-11-15"
597
+ controller.render()
598
+
599
+ const disabledButton = element.querySelector('[data-date="2024-11-15"]')
600
+ expect(disabledButton).not.toBeNull()
601
+ expect(disabledButton.classList.contains("text-muted-foreground")).toBe(true)
602
+ expect(disabledButton.classList.contains("opacity-50")).toBe(true)
603
+ expect(disabledButton.classList.contains("cursor-not-allowed")).toBe(true)
604
+ })
605
+
606
+ test("disabled dates have aria-disabled attribute", () => {
607
+ controller.disabledDatesValue = "2024-11-15"
608
+ controller.render()
609
+
610
+ const disabledButton = element.querySelector('[data-date="2024-11-15"]')
611
+ expect(disabledButton.getAttribute("aria-disabled")).toBe("true")
612
+ })
613
+
614
+ test("enabled dates do not have disabled CSS classes", () => {
615
+ controller.disabledDatesValue = "2024-11-15"
616
+ controller.render()
617
+
618
+ const enabledButton = element.querySelector('[data-date="2024-11-20"]')
619
+ expect(enabledButton).not.toBeNull()
620
+ expect(enabledButton.classList.contains("cursor-not-allowed")).toBe(false)
621
+ expect(enabledButton.getAttribute("aria-disabled")).toBeNull()
622
+ })
623
+
624
+ test("minDate disables earlier dates with correct CSS", () => {
625
+ controller.minDateValue = "2024-11-10"
626
+ controller.render()
627
+
628
+ const disabledButton = element.querySelector('[data-date="2024-11-05"]')
629
+ expect(disabledButton).not.toBeNull()
630
+ expect(disabledButton.classList.contains("text-muted-foreground")).toBe(true)
631
+ expect(disabledButton.classList.contains("cursor-not-allowed")).toBe(true)
632
+ expect(disabledButton.getAttribute("aria-disabled")).toBe("true")
633
+
634
+ const enabledButton = element.querySelector('[data-date="2024-11-15"]')
635
+ expect(enabledButton.classList.contains("cursor-not-allowed")).toBe(false)
636
+ })
637
+
638
+ test("maxDate disables later dates with correct CSS", () => {
639
+ controller.maxDateValue = "2024-11-20"
640
+ controller.render()
641
+
642
+ const disabledButton = element.querySelector('[data-date="2024-11-25"]')
643
+ expect(disabledButton).not.toBeNull()
644
+ expect(disabledButton.classList.contains("text-muted-foreground")).toBe(true)
645
+ expect(disabledButton.classList.contains("cursor-not-allowed")).toBe(true)
646
+ expect(disabledButton.getAttribute("aria-disabled")).toBe("true")
647
+
648
+ const enabledButton = element.querySelector('[data-date="2024-11-15"]')
649
+ expect(enabledButton.classList.contains("cursor-not-allowed")).toBe(false)
650
+ })
651
+
652
+ test("disabledDaysOfWeek disables weekends with correct CSS", () => {
653
+ controller.disabledDaysOfWeekValue = "0,6" // Sunday and Saturday
654
+ controller.render()
655
+
656
+ // November 16, 2024 is a Saturday
657
+ const saturdayButton = element.querySelector('[data-date="2024-11-16"]')
658
+ expect(saturdayButton).not.toBeNull()
659
+ expect(saturdayButton.classList.contains("text-muted-foreground")).toBe(true)
660
+ expect(saturdayButton.classList.contains("cursor-not-allowed")).toBe(true)
661
+ expect(saturdayButton.getAttribute("aria-disabled")).toBe("true")
662
+
663
+ // November 17, 2024 is a Sunday
664
+ const sundayButton = element.querySelector('[data-date="2024-11-17"]')
665
+ expect(sundayButton.classList.contains("cursor-not-allowed")).toBe(true)
666
+
667
+ // November 18, 2024 is a Monday - should be enabled
668
+ const mondayButton = element.querySelector('[data-date="2024-11-18"]')
669
+ expect(mondayButton.classList.contains("cursor-not-allowed")).toBe(false)
670
+ })
671
+
672
+ test("multiple disabled dates have correct CSS", () => {
673
+ controller.disabledDatesValue = "2024-11-10,2024-11-15,2024-11-20"
674
+ controller.render()
675
+
676
+ const disabledDates = ["2024-11-10", "2024-11-15", "2024-11-20"]
677
+ disabledDates.forEach(dateStr => {
678
+ const button = element.querySelector(`[data-date="${dateStr}"]`)
679
+ expect(button.classList.contains("cursor-not-allowed")).toBe(true)
680
+ expect(button.getAttribute("aria-disabled")).toBe("true")
681
+ })
682
+
683
+ // Check an enabled date between them
684
+ const enabledButton = element.querySelector('[data-date="2024-11-12"]')
685
+ expect(enabledButton.classList.contains("cursor-not-allowed")).toBe(false)
686
+ })
687
+ })
688
+
689
+ describe("snapshots", () => {
690
+ test("renders default calendar grid correctly", () => {
691
+ controller.render()
692
+
693
+ const grid = element.querySelector('[data-calendar-target="grid"]')
694
+ expect(grid.innerHTML).toMatchSnapshot()
695
+ })
696
+
697
+ test("renders calendar with selected date correctly", () => {
698
+ controller.selectedDate = new Date(2024, 10, 15)
699
+ controller.selectedValue = "2024-11-15"
700
+ controller.render()
701
+
702
+ const grid = element.querySelector('[data-calendar-target="grid"]')
703
+ expect(grid.innerHTML).toMatchSnapshot()
704
+ })
705
+
706
+ test("renders calendar with disabled dates correctly", () => {
707
+ controller.disabledDatesValue = "2024-11-10,2024-11-15,2024-11-20"
708
+ controller.render()
709
+
710
+ const grid = element.querySelector('[data-calendar-target="grid"]')
711
+ expect(grid.innerHTML).toMatchSnapshot()
712
+ })
713
+
714
+ test("renders calendar with min and max dates correctly", () => {
715
+ controller.minDateValue = "2024-11-05"
716
+ controller.maxDateValue = "2024-11-25"
717
+ controller.render()
718
+
719
+ const grid = element.querySelector('[data-calendar-target="grid"]')
720
+ expect(grid.innerHTML).toMatchSnapshot()
721
+ })
722
+
723
+ test("renders calendar with disabled weekends correctly", () => {
724
+ controller.disabledDaysOfWeekValue = "0,6"
725
+ controller.render()
726
+
727
+ const grid = element.querySelector('[data-calendar-target="grid"]')
728
+ expect(grid.innerHTML).toMatchSnapshot()
729
+ })
730
+ })
731
+
732
+ describe("disabled dates persist after interaction (regression tests)", () => {
733
+ test("disabledDaysOfWeek remains enforced after selecting a date", () => {
734
+ controller.disabledDaysOfWeekValue = "0,6" // Sunday and Saturday
735
+ controller.render()
736
+
737
+ // Get weekend buttons before interaction
738
+ const saturdayBefore = element.querySelector('[data-date="2024-11-16"]') // Saturday
739
+ const sundayBefore = element.querySelector('[data-date="2024-11-17"]') // Sunday
740
+
741
+ // Verify initially disabled
742
+ expect(saturdayBefore.classList.contains("cursor-not-allowed")).toBe(true)
743
+ expect(saturdayBefore.hasAttribute("disabled")).toBe(true)
744
+ expect(sundayBefore.classList.contains("cursor-not-allowed")).toBe(true)
745
+ expect(sundayBefore.hasAttribute("disabled")).toBe(true)
746
+
747
+ // Select a weekday date (triggers re-render)
748
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-18" } } }) // Monday
749
+
750
+ // Check weekends are STILL disabled after re-render
751
+ const saturdayAfter = element.querySelector('[data-date="2024-11-16"]')
752
+ const sundayAfter = element.querySelector('[data-date="2024-11-17"]')
753
+
754
+ expect(saturdayAfter.classList.contains("cursor-not-allowed")).toBe(true)
755
+ expect(saturdayAfter.hasAttribute("disabled")).toBe(true)
756
+ expect(saturdayAfter.getAttribute("aria-disabled")).toBe("true")
757
+ expect(sundayAfter.classList.contains("cursor-not-allowed")).toBe(true)
758
+ expect(sundayAfter.hasAttribute("disabled")).toBe(true)
759
+ expect(sundayAfter.getAttribute("aria-disabled")).toBe("true")
760
+ })
761
+
762
+ test("disabledDates remains enforced after selecting a date", () => {
763
+ controller.disabledDatesValue = "2024-11-15,2024-11-20"
764
+ controller.render()
765
+
766
+ // Verify initially disabled
767
+ const disabled15Before = element.querySelector('[data-date="2024-11-15"]')
768
+ const disabled20Before = element.querySelector('[data-date="2024-11-20"]')
769
+
770
+ expect(disabled15Before.hasAttribute("disabled")).toBe(true)
771
+ expect(disabled20Before.hasAttribute("disabled")).toBe(true)
772
+
773
+ // Select a different date (triggers re-render)
774
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-18" } } })
775
+
776
+ // Check disabled dates are STILL disabled after re-render
777
+ const disabled15After = element.querySelector('[data-date="2024-11-15"]')
778
+ const disabled20After = element.querySelector('[data-date="2024-11-20"]')
779
+
780
+ expect(disabled15After.hasAttribute("disabled")).toBe(true)
781
+ expect(disabled15After.getAttribute("aria-disabled")).toBe("true")
782
+ expect(disabled15After.classList.contains("cursor-not-allowed")).toBe(true)
783
+ expect(disabled20After.hasAttribute("disabled")).toBe(true)
784
+ expect(disabled20After.getAttribute("aria-disabled")).toBe("true")
785
+ expect(disabled20After.classList.contains("cursor-not-allowed")).toBe(true)
786
+ })
787
+
788
+ test("minDate remains enforced after selecting a date", () => {
789
+ controller.minDateValue = "2024-11-10"
790
+ controller.render()
791
+
792
+ // Verify initially disabled
793
+ const disabled5Before = element.querySelector('[data-date="2024-11-05"]')
794
+ expect(disabled5Before.hasAttribute("disabled")).toBe(true)
795
+
796
+ // Select a valid date (triggers re-render)
797
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-15" } } })
798
+
799
+ // Check dates before minDate are STILL disabled
800
+ const disabled5After = element.querySelector('[data-date="2024-11-05"]')
801
+ expect(disabled5After.hasAttribute("disabled")).toBe(true)
802
+ expect(disabled5After.getAttribute("aria-disabled")).toBe("true")
803
+ expect(disabled5After.classList.contains("cursor-not-allowed")).toBe(true)
804
+ })
805
+
806
+ test("maxDate remains enforced after selecting a date", () => {
807
+ controller.maxDateValue = "2024-11-20"
808
+ controller.render()
809
+
810
+ // Verify initially disabled
811
+ const disabled25Before = element.querySelector('[data-date="2024-11-25"]')
812
+ expect(disabled25Before.hasAttribute("disabled")).toBe(true)
813
+
814
+ // Select a valid date (triggers re-render)
815
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-15" } } })
816
+
817
+ // Check dates after maxDate are STILL disabled
818
+ const disabled25After = element.querySelector('[data-date="2024-11-25"]')
819
+ expect(disabled25After.hasAttribute("disabled")).toBe(true)
820
+ expect(disabled25After.getAttribute("aria-disabled")).toBe("true")
821
+ expect(disabled25After.classList.contains("cursor-not-allowed")).toBe(true)
822
+ })
823
+
824
+ test("disabled buttons do not have click action for date selection", () => {
825
+ controller.disabledDaysOfWeekValue = "0,6" // Weekends
826
+ controller.render()
827
+
828
+ // Disabled buttons should not have the click->selectDay action
829
+ const saturdayButton = element.querySelector('[data-date="2024-11-16"]')
830
+ const dataAction = saturdayButton.getAttribute("data-action")
831
+
832
+ expect(dataAction).not.toContain("click->")
833
+ // But should still have focus/blur handlers for keyboard
834
+ expect(dataAction).toContain("focus->")
835
+ expect(dataAction).toContain("blur->")
836
+ })
837
+
838
+ test("enabled buttons have click action for date selection", () => {
839
+ controller.disabledDaysOfWeekValue = "0,6" // Weekends
840
+ controller.render()
841
+
842
+ // Monday should have click action
843
+ const mondayButton = element.querySelector('[data-date="2024-11-18"]')
844
+ const dataAction = mondayButton.getAttribute("data-action")
845
+
846
+ expect(dataAction).toContain("click->")
847
+ })
848
+
849
+ test("clicking a disabled date does not select it", () => {
850
+ controller.disabledDaysOfWeekValue = "0,6"
851
+ controller.render()
852
+
853
+ // Try to select a Saturday
854
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-16" } } })
855
+
856
+ expect(controller.selectedDate).toBeNull()
857
+ expect(controller.selectedValue).toBe("")
858
+ })
859
+
860
+ test("multiple interactions preserve disabled state", () => {
861
+ controller.disabledDaysOfWeekValue = "0,6" // Weekends
862
+ controller.render()
863
+
864
+ // Select multiple weekday dates in sequence
865
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-18" } } }) // Monday
866
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-19" } } }) // Tuesday
867
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-20" } } }) // Wednesday
868
+
869
+ // Weekend should still be disabled after all interactions
870
+ const saturdayButton = element.querySelector('[data-date="2024-11-16"]')
871
+ const sundayButton = element.querySelector('[data-date="2024-11-17"]')
872
+
873
+ expect(saturdayButton.hasAttribute("disabled")).toBe(true)
874
+ expect(sundayButton.hasAttribute("disabled")).toBe(true)
875
+ })
876
+
877
+ test("navigation preserves disabled state", () => {
878
+ controller.disabledDaysOfWeekValue = "0,6" // Weekends
879
+ controller.render()
880
+
881
+ // Navigate to next month
882
+ controller.nextMonth()
883
+
884
+ // December 2024 - check a Saturday (Dec 7) and Sunday (Dec 8)
885
+ const saturday = element.querySelector('[data-date="2024-12-07"]')
886
+ const sunday = element.querySelector('[data-date="2024-12-08"]')
887
+
888
+ expect(saturday.hasAttribute("disabled")).toBe(true)
889
+ expect(saturday.classList.contains("cursor-not-allowed")).toBe(true)
890
+ expect(sunday.hasAttribute("disabled")).toBe(true)
891
+ expect(sunday.classList.contains("cursor-not-allowed")).toBe(true)
892
+ })
893
+ })
894
+
895
+ describe("showOutsideDays", () => {
896
+ test("renders empty placeholders when showOutsideDays is false", async () => {
897
+ application.stop()
898
+ document.body.innerHTML = ""
899
+
900
+ document.body.innerHTML = `
901
+ <div data-controller="calendar"
902
+ data-calendar-month-value="2024-11-01"
903
+ data-calendar-show-outside-days-value="false">
904
+ <div data-calendar-target="grid"></div>
905
+ </div>
906
+ `
907
+
908
+ application = Application.start()
909
+ application.register("calendar", CalendarController)
910
+
911
+ await new Promise(resolve => requestAnimationFrame(resolve))
912
+
913
+ element = document.querySelector('[data-controller="calendar"]')
914
+ controller = application.getControllerForElementAndIdentifier(element, "calendar")
915
+
916
+ controller.render()
917
+
918
+ const grid = element.querySelector('[data-calendar-target="grid"]')
919
+
920
+ // November 2024 starts on Friday, so first 5 cells should be empty divs
921
+ const emptyDivs = grid.querySelectorAll('div.h-8.w-8:not([data-date])')
922
+ expect(emptyDivs.length).toBeGreaterThan(0)
923
+
924
+ // First day button should be November 1
925
+ const firstButton = grid.querySelector('button[data-date]')
926
+ expect(firstButton.dataset.date).toBe("2024-11-01")
927
+ })
928
+
929
+ test("showOutsideDays persists after month navigation cycle", async () => {
930
+ application.stop()
931
+ document.body.innerHTML = ""
932
+
933
+ document.body.innerHTML = `
934
+ <div data-controller="calendar"
935
+ data-calendar-month-value="2024-11-01"
936
+ data-calendar-show-outside-days-value="false">
937
+ <div data-calendar-target="grid"></div>
938
+ </div>
939
+ `
940
+
941
+ application = Application.start()
942
+ application.register("calendar", CalendarController)
943
+
944
+ await new Promise(resolve => requestAnimationFrame(resolve))
945
+
946
+ element = document.querySelector('[data-controller="calendar"]')
947
+ controller = application.getControllerForElementAndIdentifier(element, "calendar")
948
+
949
+ // Navigate forward to December
950
+ controller.nextMonth()
951
+
952
+ // Verify empty placeholders in December (starts on Sunday, so first day should be Dec 1)
953
+ let grid = element.querySelector('[data-calendar-target="grid"]')
954
+ let firstButton = grid.querySelector('button[data-date]')
955
+ expect(firstButton.dataset.date).toBe("2024-12-01")
956
+
957
+ // Navigate back to November
958
+ controller.previousMonth()
959
+
960
+ // Verify empty placeholders still work in November
961
+ grid = element.querySelector('[data-calendar-target="grid"]')
962
+ firstButton = grid.querySelector('button[data-date]')
963
+ expect(firstButton.dataset.date).toBe("2024-11-01")
964
+
965
+ // Verify empty divs are still present
966
+ const emptyDivs = grid.querySelectorAll('div.h-8.w-8:not([data-date])')
967
+ expect(emptyDivs.length).toBeGreaterThan(0)
968
+ })
969
+
970
+ test("showOutsideDays: true (default) shows outside days", () => {
971
+ controller.showOutsideDaysValue = true
972
+ controller.render()
973
+
974
+ const grid = element.querySelector('[data-calendar-target="grid"]')
975
+
976
+ // November 2024 starts on Friday, so there should be October days before
977
+ const octDates = grid.querySelectorAll('button[data-date^="2024-10"]')
978
+ expect(octDates.length).toBeGreaterThan(0)
979
+ })
980
+ })
981
+
982
+ describe("month navigation with selection and disabled dates", () => {
983
+ test("disabled dates persist after navigating forward then back", () => {
984
+ controller.disabledDaysOfWeekValue = "0,6" // Weekends
985
+ controller.render()
986
+
987
+ // Verify weekends disabled in November
988
+ const novSat = element.querySelector('[data-date="2024-11-16"]')
989
+ expect(novSat.hasAttribute("disabled")).toBe(true)
990
+
991
+ // Navigate to December
992
+ controller.nextMonth()
993
+
994
+ // Verify weekends disabled in December (Dec 7 is Saturday)
995
+ const decSat = element.querySelector('[data-date="2024-12-07"]')
996
+ expect(decSat.hasAttribute("disabled")).toBe(true)
997
+
998
+ // Navigate back to November
999
+ controller.previousMonth()
1000
+
1001
+ // Verify weekends STILL disabled in November
1002
+ const novSatAgain = element.querySelector('[data-date="2024-11-16"]')
1003
+ expect(novSatAgain.hasAttribute("disabled")).toBe(true)
1004
+ expect(novSatAgain.classList.contains("cursor-not-allowed")).toBe(true)
1005
+ })
1006
+
1007
+ test("selection persists across month navigation", () => {
1008
+ // Select a date in November
1009
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-15" } } })
1010
+ expect(controller.selectedDate.getDate()).toBe(15)
1011
+
1012
+ // Navigate to December
1013
+ controller.nextMonth()
1014
+ expect(controller.currentMonth.getMonth()).toBe(11) // December
1015
+
1016
+ // Selection should still exist
1017
+ expect(controller.selectedDate.getDate()).toBe(15)
1018
+ expect(controller.selectedDate.getMonth()).toBe(10) // November
1019
+
1020
+ // Navigate back to November
1021
+ controller.previousMonth()
1022
+ expect(controller.currentMonth.getMonth()).toBe(10) // November
1023
+
1024
+ // Selected date should be visually marked
1025
+ const selectedButton = element.querySelector('[data-date="2024-11-15"]')
1026
+ expect(selectedButton.classList.contains("bg-primary")).toBe(true)
1027
+ expect(selectedButton.getAttribute("aria-selected")).toBe("true")
1028
+ })
1029
+
1030
+ test("minDate/maxDate persist across year navigation", () => {
1031
+ controller.minDateValue = "2024-06-01"
1032
+ controller.maxDateValue = "2024-12-31"
1033
+ controller.render()
1034
+
1035
+ // Navigate far back to April 2024
1036
+ controller.previousMonth() // October
1037
+ controller.previousMonth() // September
1038
+ controller.previousMonth() // August
1039
+ controller.previousMonth() // July
1040
+ controller.previousMonth() // June
1041
+ controller.previousMonth() // May
1042
+ controller.previousMonth() // April
1043
+
1044
+ // April 2024 should have all dates disabled (before minDate June 1)
1045
+ const aprilDate = element.querySelector('[data-date="2024-04-15"]')
1046
+ expect(aprilDate.hasAttribute("disabled")).toBe(true)
1047
+ expect(aprilDate.classList.contains("cursor-not-allowed")).toBe(true)
1048
+
1049
+ // Navigate forward to January 2025 (9 months from April 2024)
1050
+ for (let i = 0; i < 9; i++) {
1051
+ controller.nextMonth()
1052
+ }
1053
+
1054
+ // January 2025 should have dates disabled (after maxDate Dec 31, 2024)
1055
+ const janDate = element.querySelector('[data-date="2025-01-15"]')
1056
+ expect(janDate).not.toBeNull()
1057
+ expect(janDate.hasAttribute("disabled")).toBe(true)
1058
+ expect(janDate.classList.contains("cursor-not-allowed")).toBe(true)
1059
+ })
1060
+
1061
+ test("combined interaction: select date, navigate, select another, navigate back", () => {
1062
+ controller.disabledDaysOfWeekValue = "0,6"
1063
+ controller.render()
1064
+
1065
+ // Select Monday Nov 18
1066
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-18" } } })
1067
+ expect(controller.selectedDate.getDate()).toBe(18)
1068
+
1069
+ // Navigate to December
1070
+ controller.nextMonth()
1071
+
1072
+ // Weekends should still be disabled
1073
+ const decSat = element.querySelector('[data-date="2024-12-07"]')
1074
+ expect(decSat.hasAttribute("disabled")).toBe(true)
1075
+
1076
+ // Select a Wednesday in December (Dec 11)
1077
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-12-11" } } })
1078
+ expect(controller.selectedDate.getDate()).toBe(11)
1079
+ expect(controller.selectedDate.getMonth()).toBe(11) // December
1080
+
1081
+ // Navigate back to November
1082
+ controller.previousMonth()
1083
+
1084
+ // Old selection should no longer be marked (we selected Dec 11)
1085
+ const novDate = element.querySelector('[data-date="2024-11-18"]')
1086
+ expect(novDate.classList.contains("bg-primary")).toBe(false)
1087
+
1088
+ // Weekends should still be disabled
1089
+ const novSat = element.querySelector('[data-date="2024-11-16"]')
1090
+ expect(novSat.hasAttribute("disabled")).toBe(true)
1091
+ })
1092
+ })
1093
+
1094
+ describe("weekStartsOn", () => {
1095
+ test("default weekStartsOn is 0 (Sunday)", () => {
1096
+ expect(controller.weekStartsOnValue).toBe(0)
1097
+ })
1098
+
1099
+ test("renders grid starting from Sunday by default", () => {
1100
+ controller.render()
1101
+
1102
+ const grid = element.querySelector('[data-calendar-target="grid"]')
1103
+ const allDays = grid.querySelectorAll("button[data-date]")
1104
+
1105
+ // November 2024 starts on Friday, with Sunday start the first day should be Oct 27
1106
+ expect(allDays[0].dataset.date).toBe("2024-10-27")
1107
+ })
1108
+
1109
+ test("weekStartsOn=1 starts grid from Monday", async () => {
1110
+ application.stop()
1111
+ document.body.innerHTML = ""
1112
+
1113
+ document.body.innerHTML = `
1114
+ <div data-controller="calendar"
1115
+ data-calendar-month-value="2024-11-01"
1116
+ data-calendar-week-starts-on-value="1">
1117
+ <div data-calendar-target="grid"></div>
1118
+ </div>
1119
+ `
1120
+
1121
+ application = Application.start()
1122
+ application.register("calendar", CalendarController)
1123
+
1124
+ await new Promise(resolve => requestAnimationFrame(resolve))
1125
+
1126
+ element = document.querySelector('[data-controller="calendar"]')
1127
+ controller = application.getControllerForElementAndIdentifier(element, "calendar")
1128
+
1129
+ controller.render()
1130
+
1131
+ const grid = element.querySelector('[data-calendar-target="grid"]')
1132
+ const allDays = grid.querySelectorAll("button[data-date]")
1133
+
1134
+ // November 2024 starts on Friday, with Monday start the first day should be Oct 28
1135
+ expect(allDays[0].dataset.date).toBe("2024-10-28")
1136
+ })
1137
+
1138
+ test("weekStartsOn=1 correctly positions November 1st", async () => {
1139
+ application.stop()
1140
+ document.body.innerHTML = ""
1141
+
1142
+ document.body.innerHTML = `
1143
+ <div data-controller="calendar"
1144
+ data-calendar-month-value="2024-11-01"
1145
+ data-calendar-week-starts-on-value="1">
1146
+ <div data-calendar-target="grid"></div>
1147
+ </div>
1148
+ `
1149
+
1150
+ application = Application.start()
1151
+ application.register("calendar", CalendarController)
1152
+
1153
+ await new Promise(resolve => requestAnimationFrame(resolve))
1154
+
1155
+ element = document.querySelector('[data-controller="calendar"]')
1156
+ controller = application.getControllerForElementAndIdentifier(element, "calendar")
1157
+
1158
+ controller.render()
1159
+
1160
+ const grid = element.querySelector('[data-calendar-target="grid"]')
1161
+ const allDays = grid.querySelectorAll("button[data-date]")
1162
+
1163
+ // November 1, 2024 is Friday
1164
+ // With Monday start: Mon=0, Tue=1, Wed=2, Thu=3, Fri=4
1165
+ // First row: Oct 28 (Mon), Oct 29 (Tue), Oct 30 (Wed), Oct 31 (Thu), Nov 1 (Fri), Nov 2 (Sat), Nov 3 (Sun)
1166
+ expect(allDays[4].dataset.date).toBe("2024-11-01")
1167
+ })
1168
+
1169
+ test("weekStartsOn=1 December 2024 starts correctly", async () => {
1170
+ application.stop()
1171
+ document.body.innerHTML = ""
1172
+
1173
+ document.body.innerHTML = `
1174
+ <div data-controller="calendar"
1175
+ data-calendar-month-value="2024-12-01"
1176
+ data-calendar-week-starts-on-value="1">
1177
+ <div data-calendar-target="grid"></div>
1178
+ </div>
1179
+ `
1180
+
1181
+ application = Application.start()
1182
+ application.register("calendar", CalendarController)
1183
+
1184
+ await new Promise(resolve => requestAnimationFrame(resolve))
1185
+
1186
+ element = document.querySelector('[data-controller="calendar"]')
1187
+ controller = application.getControllerForElementAndIdentifier(element, "calendar")
1188
+
1189
+ controller.render()
1190
+
1191
+ const grid = element.querySelector('[data-calendar-target="grid"]')
1192
+ const allDays = grid.querySelectorAll("button[data-date]")
1193
+
1194
+ // December 1, 2024 is Sunday
1195
+ // With Monday start, first day should be Nov 25 (Monday)
1196
+ expect(allDays[0].dataset.date).toBe("2024-11-25")
1197
+
1198
+ // December 1 should be at position 6 (Sunday = last day of week when starting Monday)
1199
+ expect(allDays[6].dataset.date).toBe("2024-12-01")
1200
+ })
1201
+
1202
+ test("weekStartsOn persists after month navigation", async () => {
1203
+ application.stop()
1204
+ document.body.innerHTML = ""
1205
+
1206
+ document.body.innerHTML = `
1207
+ <div data-controller="calendar"
1208
+ data-calendar-month-value="2024-11-01"
1209
+ data-calendar-week-starts-on-value="1">
1210
+ <div data-calendar-target="grid"></div>
1211
+ </div>
1212
+ `
1213
+
1214
+ application = Application.start()
1215
+ application.register("calendar", CalendarController)
1216
+
1217
+ await new Promise(resolve => requestAnimationFrame(resolve))
1218
+
1219
+ element = document.querySelector('[data-controller="calendar"]')
1220
+ controller = application.getControllerForElementAndIdentifier(element, "calendar")
1221
+
1222
+ // Navigate to December
1223
+ controller.nextMonth()
1224
+
1225
+ const grid = element.querySelector('[data-calendar-target="grid"]')
1226
+ const allDays = grid.querySelectorAll("button[data-date]")
1227
+
1228
+ // Should still start from Monday (Nov 25)
1229
+ expect(allDays[0].dataset.date).toBe("2024-11-25")
1230
+
1231
+ // Navigate back to November
1232
+ controller.previousMonth()
1233
+
1234
+ const gridAfter = element.querySelector('[data-calendar-target="grid"]')
1235
+ const allDaysAfter = gridAfter.querySelectorAll("button[data-date]")
1236
+
1237
+ // Should still start from Monday (Oct 28)
1238
+ expect(allDaysAfter[0].dataset.date).toBe("2024-10-28")
1239
+ })
1240
+ })
1241
+
1242
+ describe("month navigation", () => {
1243
+ test("navigating to next month preserves selection", () => {
1244
+ // Select a date
1245
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-15" } } })
1246
+
1247
+ // Navigate to December
1248
+ controller.nextMonth()
1249
+
1250
+ // Selection should still exist
1251
+ expect(controller.selectedDate.getDate()).toBe(15)
1252
+ expect(controller.selectedDate.getMonth()).toBe(10) // November
1253
+ })
1254
+
1255
+ test("navigating back to previous month shows selection", () => {
1256
+ // Select a date
1257
+ controller.selectDay({ currentTarget: { dataset: { date: "2024-11-15" } } })
1258
+
1259
+ // Navigate to December and back
1260
+ controller.nextMonth()
1261
+ controller.previousMonth()
1262
+
1263
+ // Check selection is visible
1264
+ const selectedButton = element.querySelector('[data-date="2024-11-15"]')
1265
+ expect(selectedButton.classList.contains("bg-primary")).toBe(true)
1266
+ })
1267
+
1268
+ test("selectMonth changes month", () => {
1269
+ // Select June (index 5)
1270
+ controller.selectMonth({ target: { value: "5" } })
1271
+
1272
+ expect(controller.currentMonth.getMonth()).toBe(5) // June
1273
+ })
1274
+
1275
+ test("selectYear changes year", () => {
1276
+ // Select 2025
1277
+ controller.selectYear({ target: { value: "2025" } })
1278
+
1279
+ expect(controller.currentMonth.getFullYear()).toBe(2025)
1280
+ })
1281
+
1282
+ test("navigating through multiple months maintains state", () => {
1283
+ controller.disabledDaysOfWeekValue = "0,6"
1284
+
1285
+ // Navigate forward several months
1286
+ for (let i = 0; i < 6; i++) {
1287
+ controller.nextMonth()
1288
+ }
1289
+
1290
+ // May 2025
1291
+ expect(controller.currentMonth.getMonth()).toBe(4)
1292
+ expect(controller.currentMonth.getFullYear()).toBe(2025)
1293
+
1294
+ // Navigate back
1295
+ for (let i = 0; i < 6; i++) {
1296
+ controller.previousMonth()
1297
+ }
1298
+
1299
+ // Back to November 2024
1300
+ expect(controller.currentMonth.getMonth()).toBe(10)
1301
+ expect(controller.currentMonth.getFullYear()).toBe(2024)
1302
+
1303
+ // Disabled weekends should still work
1304
+ const saturday = element.querySelector('[data-date="2024-11-16"]')
1305
+ expect(saturday.hasAttribute("disabled")).toBe(true)
1306
+ })
1307
+ })
1308
+
1309
+ describe("range mode CSS rendering", () => {
1310
+ beforeEach(async () => {
1311
+ application.stop()
1312
+ document.body.innerHTML = ""
1313
+
1314
+ document.body.innerHTML = `
1315
+ <div data-controller="calendar"
1316
+ data-calendar-month-value="2024-11-01"
1317
+ data-calendar-mode-value="range"
1318
+ data-calendar-selected-value="">
1319
+ <div data-calendar-target="grid"></div>
1320
+ <input type="hidden" data-calendar-target="hiddenInput">
1321
+ </div>
1322
+ `
1323
+
1324
+ application = Application.start()
1325
+ application.register("calendar", CalendarController)
1326
+
1327
+ await new Promise(resolve => requestAnimationFrame(resolve))
1328
+
1329
+ element = document.querySelector('[data-controller="calendar"]')
1330
+ controller = application.getControllerForElementAndIdentifier(element, "calendar")
1331
+ })
1332
+
1333
+ test("range start has rounded-l-md class", () => {
1334
+ controller.rangeStart = new Date(2024, 10, 10)
1335
+ controller.rangeEnd = new Date(2024, 10, 15)
1336
+ controller.render()
1337
+
1338
+ const startButton = element.querySelector('[data-date="2024-11-10"]')
1339
+ expect(startButton.classList.contains("rounded-l-md")).toBe(true)
1340
+ })
1341
+
1342
+ test("range end has rounded-r-md class", () => {
1343
+ controller.rangeStart = new Date(2024, 10, 10)
1344
+ controller.rangeEnd = new Date(2024, 10, 15)
1345
+ controller.render()
1346
+
1347
+ const endButton = element.querySelector('[data-date="2024-11-15"]')
1348
+ expect(endButton.classList.contains("rounded-r-md")).toBe(true)
1349
+ })
1350
+
1351
+ test("dates in range have accent background", () => {
1352
+ controller.rangeStart = new Date(2024, 10, 10)
1353
+ controller.rangeEnd = new Date(2024, 10, 15)
1354
+ controller.render()
1355
+
1356
+ // Nov 12 is in the middle of the range
1357
+ const middleButton = element.querySelector('[data-date="2024-11-12"]')
1358
+ expect(middleButton.classList.contains("bg-accent/50")).toBe(true)
1359
+ })
1360
+
1361
+ test("range mode snapshot", () => {
1362
+ controller.rangeStart = new Date(2024, 10, 10)
1363
+ controller.rangeEnd = new Date(2024, 10, 15)
1364
+ controller.render()
1365
+
1366
+ const grid = element.querySelector('[data-calendar-target="grid"]')
1367
+ expect(grid.innerHTML).toMatchSnapshot()
1368
+ })
1369
+ })
1370
+ })