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,966 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+ import ComboboxController from "../../app/assets/javascripts/shadcn/controllers/combobox_controller.js"
3
+ import {
4
+ setupController,
5
+ cleanupController,
6
+ click,
7
+ wait,
8
+ nextFrame,
9
+ keydown,
10
+ waitForEvent,
11
+ dispatchEvent
12
+ } from "../helpers/stimulus-test-helper.js"
13
+
14
+ describe("ComboboxController", () => {
15
+ let application, element, controller
16
+
17
+ afterEach(() => {
18
+ cleanupController(application)
19
+ })
20
+
21
+ /**
22
+ * Helper to get the HTML template for combobox tests
23
+ */
24
+ function getComboboxHTML(options = {}) {
25
+ const {
26
+ open = false,
27
+ value = "",
28
+ selectedIndex = -1,
29
+ items = [
30
+ { value: "react", label: "React" },
31
+ { value: "vue", label: "Vue" },
32
+ { value: "angular", label: "Angular" },
33
+ { value: "svelte", label: "Svelte" }
34
+ ],
35
+ includeEmpty = true,
36
+ includeDisplayValue = true,
37
+ includeHiddenInput = true
38
+ } = options
39
+
40
+ const itemsHTML = items.map(item => `
41
+ <div
42
+ data-shadcn--combobox-target="item"
43
+ data-value="${item.value}"
44
+ data-label="${item.label}"
45
+ data-action="click->shadcn--combobox#select"
46
+ data-selected="false"
47
+ class="cursor-pointer"
48
+ >
49
+ <svg class="opacity-0"></svg>
50
+ ${item.label}
51
+ </div>
52
+ `).join("")
53
+
54
+ return `
55
+ <div
56
+ data-controller="shadcn--combobox"
57
+ data-shadcn--combobox-open-value="${open}"
58
+ data-shadcn--combobox-value-value="${value}"
59
+ data-shadcn--combobox-selected-index-value="${selectedIndex}"
60
+ >
61
+ <button
62
+ data-shadcn--combobox-target="trigger"
63
+ data-action="click->shadcn--combobox#toggle"
64
+ aria-expanded="${open}"
65
+ >
66
+ ${includeDisplayValue ? `<span data-shadcn--combobox-target="displayValue" class="text-muted-foreground">Select framework...</span>` : 'Select framework...'}
67
+ </button>
68
+ ${includeHiddenInput ? '<input type="hidden" data-shadcn--combobox-target="hiddenInput" name="framework">' : ''}
69
+ <div
70
+ data-shadcn--combobox-target="content"
71
+ data-state="closed"
72
+ ${!open ? 'hidden' : ''}
73
+ >
74
+ <input
75
+ data-shadcn--combobox-target="input"
76
+ type="text"
77
+ placeholder="Search..."
78
+ data-action="input->shadcn--combobox#filter"
79
+ >
80
+ <div data-shadcn--combobox-target="list">
81
+ ${itemsHTML}
82
+ </div>
83
+ ${includeEmpty ? '<div data-shadcn--combobox-target="empty" hidden>No results found.</div>' : ''}
84
+ </div>
85
+ </div>
86
+ `
87
+ }
88
+
89
+ describe("Value Initialization", () => {
90
+ it("initializes with default values", async () => {
91
+ const html = getComboboxHTML()
92
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
93
+ application = setup.application
94
+ element = setup.element
95
+ controller = setup.controller
96
+
97
+ expect(controller.openValue).toBe(false)
98
+ expect(controller.valueValue).toBe("")
99
+ expect(controller.selectedIndexValue).toBe(-1)
100
+ })
101
+
102
+ it("initializes with custom open value", async () => {
103
+ const html = getComboboxHTML({ open: true })
104
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
105
+ application = setup.application
106
+ element = setup.element
107
+ controller = setup.controller
108
+
109
+ expect(controller.openValue).toBe(true)
110
+ })
111
+
112
+ it("initializes with custom value", async () => {
113
+ const html = getComboboxHTML({ value: "react" })
114
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
115
+ application = setup.application
116
+ element = setup.element
117
+ controller = setup.controller
118
+
119
+ expect(controller.valueValue).toBe("react")
120
+ })
121
+
122
+ it("initializes with custom selected index", async () => {
123
+ const html = getComboboxHTML({ selectedIndex: 2 })
124
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
125
+ application = setup.application
126
+ element = setup.element
127
+ controller = setup.controller
128
+
129
+ expect(controller.selectedIndexValue).toBe(2)
130
+ })
131
+ })
132
+
133
+ describe("Open/Close Behavior", () => {
134
+ beforeEach(async () => {
135
+ const html = getComboboxHTML()
136
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
137
+ application = setup.application
138
+ element = setup.element
139
+ controller = setup.controller
140
+ })
141
+
142
+ it("opens the combobox when toggle is called on closed state", async () => {
143
+ expect(controller.openValue).toBe(false)
144
+
145
+ controller.toggle()
146
+ await nextFrame()
147
+
148
+ expect(controller.openValue).toBe(true)
149
+ expect(controller.contentTarget.hidden).toBe(false)
150
+ expect(controller.contentTarget.dataset.state).toBe("open")
151
+ expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("true")
152
+ })
153
+
154
+ it("closes the combobox when toggle is called on open state", async () => {
155
+ controller.open()
156
+ await nextFrame()
157
+ expect(controller.openValue).toBe(true)
158
+
159
+ controller.toggle()
160
+ await wait(250) // Wait for animation and cleanup
161
+
162
+ expect(controller.openValue).toBe(false)
163
+ expect(controller.contentTarget.dataset.state).toBe("closed")
164
+ expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("false")
165
+ })
166
+
167
+ it("focuses the input when opened", async () => {
168
+ controller.open()
169
+ await nextFrame()
170
+ await nextFrame() // requestAnimationFrame in open()
171
+
172
+ expect(document.activeElement).toBe(controller.inputTarget)
173
+ })
174
+
175
+ it("does not open if already open", async () => {
176
+ controller.open()
177
+ await nextFrame()
178
+ const initialState = controller.openValue
179
+
180
+ controller.open()
181
+ await nextFrame()
182
+
183
+ expect(controller.openValue).toBe(initialState)
184
+ expect(controller.openValue).toBe(true)
185
+ })
186
+
187
+ it("does not close if already closed", async () => {
188
+ expect(controller.openValue).toBe(false)
189
+
190
+ controller.close()
191
+ await wait(250)
192
+
193
+ expect(controller.openValue).toBe(false)
194
+ })
195
+
196
+ it("resets selected index when opened", async () => {
197
+ controller.selectedIndexValue = 2
198
+
199
+ controller.open()
200
+ await nextFrame()
201
+
202
+ expect(controller.selectedIndexValue).toBe(-1)
203
+ })
204
+
205
+ it("closes on Escape key", async () => {
206
+ controller.open()
207
+ await nextFrame()
208
+
209
+ keydown(document, "Escape")
210
+ await wait(250)
211
+
212
+ expect(controller.openValue).toBe(false)
213
+ })
214
+
215
+ it("hides content after close animation completes", async () => {
216
+ controller.open()
217
+ await nextFrame()
218
+ expect(controller.contentTarget.hidden).toBe(false)
219
+
220
+ controller.close()
221
+ await wait(250) // Wait for animation and fallback timeout
222
+
223
+ expect(controller.contentTarget.hidden).toBe(true)
224
+ })
225
+
226
+ it("resets input value when closed", async () => {
227
+ controller.open()
228
+ await nextFrame()
229
+
230
+ controller.inputTarget.value = "test search"
231
+ controller.close()
232
+ await wait(250)
233
+
234
+ expect(controller.inputTarget.value).toBe("")
235
+ })
236
+
237
+ it("resets item visibility when closed", async () => {
238
+ controller.open()
239
+ await nextFrame()
240
+
241
+ // Hide some items
242
+ controller.itemTargets[0].style.display = "none"
243
+ controller.itemTargets[1].style.display = "none"
244
+
245
+ controller.close()
246
+ await wait(250)
247
+
248
+ controller.itemTargets.forEach(item => {
249
+ expect(item.style.display).toBe("")
250
+ })
251
+ })
252
+
253
+ it("hides empty state when closed", async () => {
254
+ controller.open()
255
+ await nextFrame()
256
+
257
+ controller.emptyTarget.hidden = false
258
+ controller.close()
259
+ await wait(250)
260
+
261
+ expect(controller.emptyTarget.hidden).toBe(true)
262
+ })
263
+
264
+ it("adds keyboard listener when opened", async () => {
265
+ const spy = jest.spyOn(document, "addEventListener")
266
+
267
+ controller.open()
268
+ await nextFrame()
269
+
270
+ expect(spy).toHaveBeenCalledWith("keydown", controller.boundHandleKeydown)
271
+ spy.mockRestore()
272
+ })
273
+
274
+ it("removes keyboard listener when closed", async () => {
275
+ controller.open()
276
+ await nextFrame()
277
+
278
+ const spy = jest.spyOn(document, "removeEventListener")
279
+ controller.close()
280
+ await wait(250)
281
+
282
+ expect(spy).toHaveBeenCalledWith("keydown", controller.boundHandleKeydown)
283
+ spy.mockRestore()
284
+ })
285
+ })
286
+
287
+ describe("Filtering", () => {
288
+ beforeEach(async () => {
289
+ const html = getComboboxHTML()
290
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
291
+ application = setup.application
292
+ element = setup.element
293
+ controller = setup.controller
294
+ })
295
+
296
+ it("filters items based on input value", () => {
297
+ controller.inputTarget.value = "react"
298
+ controller.filter()
299
+
300
+ expect(controller.itemTargets[0].style.display).toBe("") // React - visible
301
+ expect(controller.itemTargets[1].style.display).toBe("none") // Vue - hidden
302
+ expect(controller.itemTargets[2].style.display).toBe("none") // Angular - hidden
303
+ expect(controller.itemTargets[3].style.display).toBe("none") // Svelte - hidden
304
+ })
305
+
306
+ it("is case insensitive when filtering", () => {
307
+ controller.inputTarget.value = "REACT"
308
+ controller.filter()
309
+
310
+ expect(controller.itemTargets[0].style.display).toBe("") // React matches
311
+ })
312
+
313
+ it("filters by label attribute", () => {
314
+ controller.inputTarget.value = "Vue"
315
+ controller.filter()
316
+
317
+ expect(controller.itemTargets[0].style.display).toBe("none")
318
+ expect(controller.itemTargets[1].style.display).toBe("") // Vue visible
319
+ expect(controller.itemTargets[2].style.display).toBe("none")
320
+ expect(controller.itemTargets[3].style.display).toBe("none")
321
+ })
322
+
323
+ it("filters by value attribute", () => {
324
+ controller.inputTarget.value = "angular"
325
+ controller.filter()
326
+
327
+ expect(controller.itemTargets[0].style.display).toBe("none")
328
+ expect(controller.itemTargets[1].style.display).toBe("none")
329
+ expect(controller.itemTargets[2].style.display).toBe("") // Angular visible
330
+ expect(controller.itemTargets[3].style.display).toBe("none")
331
+ })
332
+
333
+ it("shows all items when input is empty", () => {
334
+ controller.inputTarget.value = "react"
335
+ controller.filter()
336
+
337
+ controller.inputTarget.value = ""
338
+ controller.filter()
339
+
340
+ controller.itemTargets.forEach(item => {
341
+ expect(item.style.display).toBe("")
342
+ })
343
+ })
344
+
345
+ it("shows empty state when no results match query", () => {
346
+ controller.inputTarget.value = "nonexistent"
347
+ controller.filter()
348
+
349
+ expect(controller.emptyTarget.hidden).toBe(false)
350
+ })
351
+
352
+ it("hides empty state when results exist", () => {
353
+ controller.emptyTarget.hidden = false
354
+
355
+ controller.inputTarget.value = "react"
356
+ controller.filter()
357
+
358
+ expect(controller.emptyTarget.hidden).toBe(true)
359
+ })
360
+
361
+ it("hides empty state when query is empty", () => {
362
+ controller.emptyTarget.hidden = false
363
+
364
+ controller.inputTarget.value = ""
365
+ controller.filter()
366
+
367
+ expect(controller.emptyTarget.hidden).toBe(true)
368
+ })
369
+
370
+ it("resets selected index after filtering", () => {
371
+ controller.selectedIndexValue = 2
372
+
373
+ controller.inputTarget.value = "react"
374
+ controller.filter()
375
+
376
+ expect(controller.selectedIndexValue).toBe(-1)
377
+ })
378
+
379
+ it("handles partial matches", () => {
380
+ controller.inputTarget.value = "vue"
381
+ controller.filter()
382
+
383
+ expect(controller.itemTargets[1].style.display).toBe("") // Vue
384
+ expect(controller.itemTargets[3].style.display).toBe("none") // Svelte (contains 'v' but not 'vue')
385
+ })
386
+
387
+ it("trims whitespace from query", () => {
388
+ controller.inputTarget.value = " react "
389
+ controller.filter()
390
+
391
+ expect(controller.itemTargets[0].style.display).toBe("") // React visible
392
+ })
393
+ })
394
+
395
+ describe("Selection", () => {
396
+ beforeEach(async () => {
397
+ const html = getComboboxHTML()
398
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
399
+ application = setup.application
400
+ element = setup.element
401
+ controller = setup.controller
402
+ })
403
+
404
+ it("selects item on click", () => {
405
+ const item = controller.itemTargets[0]
406
+
407
+ click(item)
408
+
409
+ expect(controller.valueValue).toBe("react")
410
+ })
411
+
412
+ it("updates hidden input value when item selected", () => {
413
+ const item = controller.itemTargets[1]
414
+
415
+ click(item)
416
+
417
+ expect(controller.hiddenInputTarget.value).toBe("vue")
418
+ })
419
+
420
+ it("updates display value text when item selected", () => {
421
+ const item = controller.itemTargets[2]
422
+
423
+ click(item)
424
+
425
+ expect(controller.displayValueTarget.textContent).toBe("Angular")
426
+ })
427
+
428
+ it("removes muted foreground class from display value", () => {
429
+ controller.displayValueTarget.classList.add("text-muted-foreground")
430
+ const item = controller.itemTargets[0]
431
+
432
+ click(item)
433
+
434
+ expect(controller.displayValueTarget.classList.contains("text-muted-foreground")).toBe(false)
435
+ })
436
+
437
+ it("updates selected state on items", () => {
438
+ const item = controller.itemTargets[1]
439
+
440
+ click(item)
441
+
442
+ expect(controller.itemTargets[0].dataset.selected).toBe("false")
443
+ expect(controller.itemTargets[1].dataset.selected).toBe("true")
444
+ expect(controller.itemTargets[2].dataset.selected).toBe("false")
445
+ expect(controller.itemTargets[3].dataset.selected).toBe("false")
446
+ })
447
+
448
+ it("updates check icon visibility for selected item", () => {
449
+ const item = controller.itemTargets[0]
450
+ const checkIcon = item.querySelector("svg")
451
+
452
+ click(item)
453
+
454
+ expect(checkIcon.classList.contains("opacity-100")).toBe(true)
455
+ expect(checkIcon.classList.contains("opacity-0")).toBe(false)
456
+ })
457
+
458
+ it("hides check icon for unselected items", () => {
459
+ click(controller.itemTargets[0])
460
+
461
+ // Select different item
462
+ click(controller.itemTargets[1])
463
+
464
+ const firstCheckIcon = controller.itemTargets[0].querySelector("svg")
465
+ expect(firstCheckIcon.classList.contains("opacity-0")).toBe(true)
466
+ expect(firstCheckIcon.classList.contains("opacity-100")).toBe(false)
467
+ })
468
+
469
+ it("dispatches change event with value and label", async () => {
470
+ const item = controller.itemTargets[2]
471
+ const eventPromise = waitForEvent(element, "shadcn--combobox:change", 1000)
472
+
473
+ click(item)
474
+ const event = await eventPromise
475
+
476
+ expect(event.detail.value).toBe("angular")
477
+ expect(event.detail.label).toBe("Angular")
478
+ })
479
+
480
+ it("closes combobox after selection", async () => {
481
+ controller.open()
482
+ await nextFrame()
483
+ expect(controller.openValue).toBe(true)
484
+
485
+ click(controller.itemTargets[0])
486
+ await wait(250)
487
+
488
+ expect(controller.openValue).toBe(false)
489
+ })
490
+
491
+ it("selects item on Enter key when item is highlighted", async () => {
492
+ controller.open()
493
+ await nextFrame()
494
+
495
+ controller.selectedIndexValue = 1
496
+ controller.updateSelection()
497
+
498
+ keydown(document, "Enter")
499
+ await wait(250)
500
+
501
+ expect(controller.valueValue).toBe("vue")
502
+ })
503
+
504
+ it("does nothing on Enter if no item is highlighted", () => {
505
+ controller.open()
506
+
507
+ const initialValue = controller.valueValue
508
+ keydown(document, "Enter")
509
+
510
+ expect(controller.valueValue).toBe(initialValue)
511
+ })
512
+ })
513
+
514
+ describe("Value Persistence", () => {
515
+ beforeEach(async () => {
516
+ const html = getComboboxHTML()
517
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
518
+ application = setup.application
519
+ element = setup.element
520
+ controller = setup.controller
521
+ })
522
+
523
+ it("maintains selected value after filtering", () => {
524
+ click(controller.itemTargets[0]) // Select React
525
+
526
+ controller.inputTarget.value = "vue"
527
+ controller.filter()
528
+
529
+ expect(controller.valueValue).toBe("react")
530
+ })
531
+
532
+ it("maintains selected value after closing and reopening", async () => {
533
+ controller.open()
534
+ await nextFrame()
535
+
536
+ click(controller.itemTargets[1]) // Select Vue
537
+ await wait(250)
538
+
539
+ controller.open()
540
+ await nextFrame()
541
+
542
+ expect(controller.valueValue).toBe("vue")
543
+ expect(controller.itemTargets[1].dataset.selected).toBe("true")
544
+ })
545
+
546
+ it("maintains display value after reopening", async () => {
547
+ controller.open()
548
+ await nextFrame()
549
+
550
+ click(controller.itemTargets[2]) // Select Angular
551
+ await wait(250)
552
+
553
+ controller.open()
554
+ await nextFrame()
555
+
556
+ expect(controller.displayValueTarget.textContent).toBe("Angular")
557
+ })
558
+ })
559
+
560
+ describe("Animation Timing", () => {
561
+ beforeEach(async () => {
562
+ const html = getComboboxHTML()
563
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
564
+ application = setup.application
565
+ element = setup.element
566
+ controller = setup.controller
567
+ })
568
+
569
+ it("sets state to open immediately when opening", () => {
570
+ controller.open()
571
+
572
+ expect(controller.contentTarget.dataset.state).toBe("open")
573
+ })
574
+
575
+ it("sets state to closed immediately when closing", () => {
576
+ controller.open()
577
+ controller.close()
578
+
579
+ expect(controller.contentTarget.dataset.state).toBe("closed")
580
+ })
581
+
582
+ it("hides content after close animation with fallback timeout", async () => {
583
+ controller.open()
584
+ await nextFrame()
585
+
586
+ controller.close()
587
+
588
+ // Content should still be visible during animation
589
+ expect(controller.contentTarget.hidden).toBe(false)
590
+
591
+ // Wait for fallback timeout (200ms)
592
+ await wait(250)
593
+
594
+ // Content should now be hidden
595
+ expect(controller.contentTarget.hidden).toBe(true)
596
+ })
597
+
598
+ it("listens for animationend event on close", async () => {
599
+ controller.open()
600
+ await nextFrame()
601
+
602
+ const spy = jest.spyOn(controller.contentTarget, "addEventListener")
603
+ controller.close()
604
+
605
+ expect(spy).toHaveBeenCalledWith("animationend", expect.any(Function))
606
+ spy.mockRestore()
607
+ await wait(250)
608
+ })
609
+ })
610
+
611
+ describe("Keyboard Navigation", () => {
612
+ beforeEach(async () => {
613
+ const html = getComboboxHTML()
614
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
615
+ application = setup.application
616
+ element = setup.element
617
+ controller = setup.controller
618
+ controller.open()
619
+ await nextFrame()
620
+ })
621
+
622
+ it("navigates down with ArrowDown", () => {
623
+ expect(controller.selectedIndexValue).toBe(-1)
624
+
625
+ keydown(document, "ArrowDown")
626
+ expect(controller.selectedIndexValue).toBe(0)
627
+
628
+ keydown(document, "ArrowDown")
629
+ expect(controller.selectedIndexValue).toBe(1)
630
+ })
631
+
632
+ it("navigates up with ArrowUp", () => {
633
+ controller.selectedIndexValue = 2
634
+
635
+ keydown(document, "ArrowUp")
636
+ expect(controller.selectedIndexValue).toBe(1)
637
+
638
+ keydown(document, "ArrowUp")
639
+ expect(controller.selectedIndexValue).toBe(0)
640
+ })
641
+
642
+ it("does not go below 0 with ArrowUp", () => {
643
+ controller.selectedIndexValue = 0
644
+
645
+ keydown(document, "ArrowUp")
646
+ expect(controller.selectedIndexValue).toBe(0)
647
+ })
648
+
649
+ it("does not go beyond last item with ArrowDown", () => {
650
+ const lastIndex = controller.itemTargets.length - 1
651
+ controller.selectedIndexValue = lastIndex
652
+
653
+ keydown(document, "ArrowDown")
654
+ expect(controller.selectedIndexValue).toBe(lastIndex)
655
+ })
656
+
657
+ it("applies highlight classes to selected item", () => {
658
+ controller.selectedIndexValue = 1
659
+ controller.updateSelection()
660
+
661
+ expect(controller.itemTargets[1].classList.contains("bg-accent")).toBe(true)
662
+ expect(controller.itemTargets[1].classList.contains("text-accent-foreground")).toBe(true)
663
+ })
664
+
665
+ it("removes highlight classes from unselected items", () => {
666
+ controller.selectedIndexValue = 1
667
+ controller.updateSelection()
668
+
669
+ expect(controller.itemTargets[0].classList.contains("bg-accent")).toBe(false)
670
+ expect(controller.itemTargets[2].classList.contains("bg-accent")).toBe(false)
671
+ })
672
+
673
+ it("scrolls selected item into view", () => {
674
+ const spy = jest.spyOn(controller.itemTargets[2], "scrollIntoView")
675
+
676
+ controller.selectedIndexValue = 2
677
+ controller.updateSelection()
678
+
679
+ expect(spy).toHaveBeenCalledWith({ block: "nearest" })
680
+ spy.mockRestore()
681
+ })
682
+
683
+ it("navigates only through visible items after filtering", () => {
684
+ controller.inputTarget.value = "react"
685
+ controller.filter()
686
+
687
+ keydown(document, "ArrowDown")
688
+
689
+ // Should select first (and only) visible item
690
+ expect(controller.selectedIndexValue).toBe(0)
691
+
692
+ keydown(document, "ArrowDown")
693
+
694
+ // Should stay at 0 since it's the last visible item
695
+ expect(controller.selectedIndexValue).toBe(0)
696
+ })
697
+
698
+ it("prevents default on navigation keys", () => {
699
+ const event = new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true })
700
+ const spy = jest.spyOn(event, "preventDefault")
701
+
702
+ document.dispatchEvent(event)
703
+
704
+ expect(spy).toHaveBeenCalled()
705
+ })
706
+
707
+ it("prevents default on Escape key", () => {
708
+ const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })
709
+ const spy = jest.spyOn(event, "preventDefault")
710
+
711
+ document.dispatchEvent(event)
712
+
713
+ expect(spy).toHaveBeenCalled()
714
+ })
715
+
716
+ it("prevents default on Enter key", () => {
717
+ controller.selectedIndexValue = 0
718
+ const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })
719
+ const spy = jest.spyOn(event, "preventDefault")
720
+
721
+ document.dispatchEvent(event)
722
+
723
+ expect(spy).toHaveBeenCalled()
724
+ })
725
+ })
726
+
727
+ describe("ARIA Attributes", () => {
728
+ beforeEach(async () => {
729
+ const html = getComboboxHTML()
730
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
731
+ application = setup.application
732
+ element = setup.element
733
+ controller = setup.controller
734
+ })
735
+
736
+ it("sets aria-expanded to false when closed", () => {
737
+ expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("false")
738
+ })
739
+
740
+ it("sets aria-expanded to true when opened", async () => {
741
+ controller.open()
742
+ await nextFrame()
743
+
744
+ expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("true")
745
+ })
746
+
747
+ it("updates aria-expanded when toggling", async () => {
748
+ controller.toggle()
749
+ await nextFrame()
750
+ expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("true")
751
+
752
+ controller.toggle()
753
+ await wait(250)
754
+ expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("false")
755
+ })
756
+ })
757
+
758
+ describe("Helper Methods", () => {
759
+ beforeEach(async () => {
760
+ const html = getComboboxHTML()
761
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
762
+ application = setup.application
763
+ element = setup.element
764
+ controller = setup.controller
765
+ })
766
+
767
+ it("getVisibleItems returns all items when none are filtered", () => {
768
+ const visibleItems = controller.getVisibleItems()
769
+ expect(visibleItems.length).toBe(4)
770
+ })
771
+
772
+ it("getVisibleItems returns only visible items after filtering", () => {
773
+ controller.itemTargets[0].style.display = "none"
774
+ controller.itemTargets[2].style.display = "none"
775
+
776
+ const visibleItems = controller.getVisibleItems()
777
+
778
+ expect(visibleItems.length).toBe(2)
779
+ expect(visibleItems[0]).toBe(controller.itemTargets[1])
780
+ expect(visibleItems[1]).toBe(controller.itemTargets[3])
781
+ })
782
+ })
783
+
784
+ describe("Click Outside Handling", () => {
785
+ beforeEach(async () => {
786
+ const html = getComboboxHTML()
787
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
788
+ application = setup.application
789
+ element = setup.element
790
+ controller = setup.controller
791
+ })
792
+
793
+ it("closes when clicking outside the element", async () => {
794
+ controller.open()
795
+ await nextFrame()
796
+
797
+ const outsideElement = document.createElement("div")
798
+ document.body.appendChild(outsideElement)
799
+
800
+ const event = new MouseEvent("click", { bubbles: true })
801
+ Object.defineProperty(event, "target", { value: outsideElement, enumerable: true })
802
+
803
+ controller.handleClickOutside(event)
804
+ await wait(250)
805
+
806
+ expect(controller.openValue).toBe(false)
807
+ outsideElement.remove()
808
+ })
809
+
810
+ it("does not close when clicking inside the element", async () => {
811
+ controller.open()
812
+ await nextFrame()
813
+
814
+ const event = new MouseEvent("click", { bubbles: true })
815
+ Object.defineProperty(event, "target", { value: controller.inputTarget, enumerable: true })
816
+
817
+ controller.handleClickOutside(event)
818
+ await nextFrame()
819
+
820
+ expect(controller.openValue).toBe(true)
821
+ })
822
+
823
+ it("does nothing when already closed", () => {
824
+ expect(controller.openValue).toBe(false)
825
+
826
+ const outsideElement = document.createElement("div")
827
+ document.body.appendChild(outsideElement)
828
+
829
+ const event = new MouseEvent("click", { bubbles: true })
830
+ Object.defineProperty(event, "target", { value: outsideElement, enumerable: true })
831
+
832
+ controller.handleClickOutside(event)
833
+
834
+ expect(controller.openValue).toBe(false)
835
+ outsideElement.remove()
836
+ })
837
+ })
838
+
839
+ describe("Disconnect", () => {
840
+ beforeEach(async () => {
841
+ const html = getComboboxHTML()
842
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
843
+ application = setup.application
844
+ element = setup.element
845
+ controller = setup.controller
846
+ })
847
+
848
+ it("removes keyboard event listener on disconnect", () => {
849
+ controller.open()
850
+
851
+ const spy = jest.spyOn(document, "removeEventListener")
852
+ controller.disconnect()
853
+
854
+ expect(spy).toHaveBeenCalledWith("keydown", controller.boundHandleKeydown)
855
+ spy.mockRestore()
856
+ })
857
+ })
858
+
859
+ describe("Edge Cases", () => {
860
+ it("handles combobox without empty target", async () => {
861
+ const html = getComboboxHTML({ includeEmpty: false })
862
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
863
+ application = setup.application
864
+ element = setup.element
865
+ controller = setup.controller
866
+
867
+ expect(controller.hasEmptyTarget).toBe(false)
868
+
869
+ controller.inputTarget.value = "nonexistent"
870
+
871
+ // Should not throw error
872
+ expect(() => controller.filter()).not.toThrow()
873
+ })
874
+
875
+ it("handles combobox without display value target", async () => {
876
+ const html = getComboboxHTML({ includeDisplayValue: false })
877
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
878
+ application = setup.application
879
+ element = setup.element
880
+ controller = setup.controller
881
+
882
+ expect(controller.hasDisplayValueTarget).toBe(false)
883
+
884
+ // Should not throw error when selecting
885
+ expect(() => click(controller.itemTargets[0])).not.toThrow()
886
+ })
887
+
888
+ it("handles combobox without hidden input target", async () => {
889
+ const html = getComboboxHTML({ includeHiddenInput: false })
890
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
891
+ application = setup.application
892
+ element = setup.element
893
+ controller = setup.controller
894
+
895
+ expect(controller.hasHiddenInputTarget).toBe(false)
896
+
897
+ // Should not throw error when selecting
898
+ expect(() => click(controller.itemTargets[0])).not.toThrow()
899
+ })
900
+
901
+ it("handles items without check icons", () => {
902
+ const html = `
903
+ <div data-controller="shadcn--combobox">
904
+ <button data-shadcn--combobox-target="trigger" data-action="click->shadcn--combobox#toggle">
905
+ Select
906
+ </button>
907
+ <div data-shadcn--combobox-target="content" hidden>
908
+ <input data-shadcn--combobox-target="input" type="text" data-action="input->shadcn--combobox#filter">
909
+ <div data-shadcn--combobox-target="item" data-value="item1" data-label="Item 1" data-action="click->shadcn--combobox#select">
910
+ Item 1
911
+ </div>
912
+ </div>
913
+ </div>
914
+ `
915
+
916
+ return setupController(ComboboxController, html, "shadcn--combobox").then(setup => {
917
+ application = setup.application
918
+ element = setup.element
919
+ controller = setup.controller
920
+
921
+ // Should not throw error when selecting item without icon
922
+ expect(() => click(controller.itemTargets[0])).not.toThrow()
923
+ })
924
+ })
925
+
926
+ it("handles empty item list", async () => {
927
+ const html = getComboboxHTML({ items: [] })
928
+ const setup = await setupController(ComboboxController, html, "shadcn--combobox")
929
+ application = setup.application
930
+ element = setup.element
931
+ controller = setup.controller
932
+
933
+ expect(controller.itemTargets.length).toBe(0)
934
+
935
+ // Should not throw errors
936
+ expect(() => controller.filter()).not.toThrow()
937
+ expect(() => keydown(document, "ArrowDown")).not.toThrow()
938
+ expect(() => controller.updateSelection()).not.toThrow()
939
+ })
940
+
941
+ it("handles item without label attribute falling back to textContent", () => {
942
+ const html = `
943
+ <div data-controller="shadcn--combobox">
944
+ <button data-shadcn--combobox-target="trigger"></button>
945
+ <div data-shadcn--combobox-target="content" hidden>
946
+ <input data-shadcn--combobox-target="input" type="text" data-action="input->shadcn--combobox#filter">
947
+ <div data-shadcn--combobox-target="item" data-value="test" data-action="click->shadcn--combobox#select">
948
+ Text Content Only
949
+ </div>
950
+ </div>
951
+ </div>
952
+ `
953
+
954
+ return setupController(ComboboxController, html, "shadcn--combobox").then(setup => {
955
+ application = setup.application
956
+ element = setup.element
957
+ controller = setup.controller
958
+
959
+ controller.inputTarget.value = "text"
960
+ controller.filter()
961
+
962
+ expect(controller.itemTargets[0].style.display).toBe("")
963
+ })
964
+ })
965
+ })
966
+ })