shadcn_view_component 0.3.3

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 (507) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +255 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +604 -0
  5. data/app/assets/stylesheets/shadcn-themes.css +1075 -0
  6. data/app/assets/stylesheets/shadcn.css +853 -0
  7. data/app/components/shadcn/accordion/component.rb +44 -0
  8. data/app/components/shadcn/accordion/content/component.rb +44 -0
  9. data/app/components/shadcn/accordion/item/component.rb +41 -0
  10. data/app/components/shadcn/accordion/preview.rb +16 -0
  11. data/app/components/shadcn/accordion/previews/default.html.erb +10 -0
  12. data/app/components/shadcn/accordion/previews/multiple.html.erb +13 -0
  13. data/app/components/shadcn/accordion/trigger/component.rb +52 -0
  14. data/app/components/shadcn/alert/component.rb +52 -0
  15. data/app/components/shadcn/alert/preview.rb +11 -0
  16. data/app/components/shadcn/alert/previews/default.html.erb +8 -0
  17. data/app/components/shadcn/alert.rb +17 -0
  18. data/app/components/shadcn/alert_dialog/action/component.rb +35 -0
  19. data/app/components/shadcn/alert_dialog/cancel/component.rb +16 -0
  20. data/app/components/shadcn/alert_dialog/component.rb +20 -0
  21. data/app/components/shadcn/alert_dialog/content/component.rb +55 -0
  22. data/app/components/shadcn/alert_dialog/header/component.rb +33 -0
  23. data/app/components/shadcn/alert_dialog/preview.rb +16 -0
  24. data/app/components/shadcn/alert_dialog/previews/default.html.erb +13 -0
  25. data/app/components/shadcn/alert_dialog/previews/small_with_media.html.erb +38 -0
  26. data/app/components/shadcn/alert_dialog.rb +32 -0
  27. data/app/components/shadcn/application_view_component.rb +225 -0
  28. data/app/components/shadcn/application_view_component_preview.rb +8 -0
  29. data/app/components/shadcn/aspect_ratio/component.rb +34 -0
  30. data/app/components/shadcn/aspect_ratio/preview.rb +16 -0
  31. data/app/components/shadcn/aspect_ratio/previews/default.html.erb +5 -0
  32. data/app/components/shadcn/aspect_ratio/previews/ratios.html.erb +12 -0
  33. data/app/components/shadcn/attachment/action/component.rb +19 -0
  34. data/app/components/shadcn/attachment/component.rb +76 -0
  35. data/app/components/shadcn/attachment/media/component.rb +56 -0
  36. data/app/components/shadcn/attachment/preview.rb +11 -0
  37. data/app/components/shadcn/attachment/previews/default.html.erb +80 -0
  38. data/app/components/shadcn/attachment/trigger/component.rb +31 -0
  39. data/app/components/shadcn/attachment.rb +42 -0
  40. data/app/components/shadcn/avatar/component.rb +43 -0
  41. data/app/components/shadcn/avatar/fallback/component.rb +23 -0
  42. data/app/components/shadcn/avatar/image/component.rb +20 -0
  43. data/app/components/shadcn/avatar/preview.rb +11 -0
  44. data/app/components/shadcn/avatar/previews/default.html.erb +13 -0
  45. data/app/components/shadcn/avatar.rb +32 -0
  46. data/app/components/shadcn/badge/component.rb +57 -0
  47. data/app/components/shadcn/badge/preview.rb +11 -0
  48. data/app/components/shadcn/badge/previews/default.html.erb +5 -0
  49. data/app/components/shadcn/breadcrumb/component.rb +15 -0
  50. data/app/components/shadcn/breadcrumb/ellipsis/component.rb +28 -0
  51. data/app/components/shadcn/breadcrumb/page/component.rb +25 -0
  52. data/app/components/shadcn/breadcrumb/preview.rb +16 -0
  53. data/app/components/shadcn/breadcrumb/previews/custom_separator.html.erb +17 -0
  54. data/app/components/shadcn/breadcrumb/previews/default.html.erb +15 -0
  55. data/app/components/shadcn/breadcrumb/separator/component.rb +31 -0
  56. data/app/components/shadcn/breadcrumb.rb +18 -0
  57. data/app/components/shadcn/bubble/component.rb +90 -0
  58. data/app/components/shadcn/bubble/preview.rb +16 -0
  59. data/app/components/shadcn/bubble/previews/alignment.html.erb +33 -0
  60. data/app/components/shadcn/bubble/previews/default.html.erb +22 -0
  61. data/app/components/shadcn/bubble/reactions/component.rb +54 -0
  62. data/app/components/shadcn/bubble.rb +24 -0
  63. data/app/components/shadcn/button/component.rb +85 -0
  64. data/app/components/shadcn/button/preview.rb +31 -0
  65. data/app/components/shadcn/button/previews/default.html.erb +8 -0
  66. data/app/components/shadcn/button/previews/sizes.html.erb +5 -0
  67. data/app/components/shadcn/button/previews/variants.html.erb +5 -0
  68. data/app/components/shadcn/button_group/component.rb +53 -0
  69. data/app/components/shadcn/button_group/preview.rb +15 -0
  70. data/app/components/shadcn/button_group/previews/default.html.erb +18 -0
  71. data/app/components/shadcn/button_group/previews/vertical.html.erb +5 -0
  72. data/app/components/shadcn/button_group/separator/component.rb +30 -0
  73. data/app/components/shadcn/button_group/text/component.rb +20 -0
  74. data/app/components/shadcn/calendar/component.rb +558 -0
  75. data/app/components/shadcn/calendar/month.rb +74 -0
  76. data/app/components/shadcn/calendar/preview.rb +64 -0
  77. data/app/components/shadcn/calendar/previews/booked.html.erb +18 -0
  78. data/app/components/shadcn/calendar/previews/custom_cell_size.html.erb +11 -0
  79. data/app/components/shadcn/calendar/previews/date_and_time.html.erb +31 -0
  80. data/app/components/shadcn/calendar/previews/default.html.erb +8 -0
  81. data/app/components/shadcn/calendar/previews/dropdown_caption.html.erb +11 -0
  82. data/app/components/shadcn/calendar/previews/in_a_form.html.erb +8 -0
  83. data/app/components/shadcn/calendar/previews/presets.html.erb +20 -0
  84. data/app/components/shadcn/calendar/previews/range.html.erb +10 -0
  85. data/app/components/shadcn/calendar/previews/week_numbers.html.erb +8 -0
  86. data/app/components/shadcn/card/component.rb +24 -0
  87. data/app/components/shadcn/card/header/component.rb +26 -0
  88. data/app/components/shadcn/card/preview.rb +16 -0
  89. data/app/components/shadcn/card/previews/default.html.erb +18 -0
  90. data/app/components/shadcn/card/previews/image.html.erb +37 -0
  91. data/app/components/shadcn/card.rb +21 -0
  92. data/app/components/shadcn/carousel/component.rb +61 -0
  93. data/app/components/shadcn/carousel/content/component.rb +86 -0
  94. data/app/components/shadcn/carousel/item/component.rb +42 -0
  95. data/app/components/shadcn/carousel/next/component.rb +59 -0
  96. data/app/components/shadcn/carousel/preview.rb +22 -0
  97. data/app/components/shadcn/carousel/previews/default.html.erb +22 -0
  98. data/app/components/shadcn/carousel/previews/sizes.html.erb +42 -0
  99. data/app/components/shadcn/carousel/previews/vertical.html.erb +27 -0
  100. data/app/components/shadcn/carousel/previous/component.rb +59 -0
  101. data/app/components/shadcn/chart/area/component.rb +43 -0
  102. data/app/components/shadcn/chart/bar/component.rb +75 -0
  103. data/app/components/shadcn/chart/cartesian/component.rb +152 -0
  104. data/app/components/shadcn/chart/component.rb +124 -0
  105. data/app/components/shadcn/chart/focusable.rb +38 -0
  106. data/app/components/shadcn/chart/legend/component.rb +55 -0
  107. data/app/components/shadcn/chart/line/component.rb +50 -0
  108. data/app/components/shadcn/chart/pie/component.rb +169 -0
  109. data/app/components/shadcn/chart/plot.rb +147 -0
  110. data/app/components/shadcn/chart/preview.rb +45 -0
  111. data/app/components/shadcn/chart/previews/area.html.erb +31 -0
  112. data/app/components/shadcn/chart/previews/bar.html.erb +32 -0
  113. data/app/components/shadcn/chart/previews/bar_stacked.html.erb +30 -0
  114. data/app/components/shadcn/chart/previews/default.html.erb +30 -0
  115. data/app/components/shadcn/chart/previews/donut.html.erb +16 -0
  116. data/app/components/shadcn/chart/previews/line.html.erb +30 -0
  117. data/app/components/shadcn/chart/previews/percentages.html.erb +17 -0
  118. data/app/components/shadcn/chart/table/component.rb +75 -0
  119. data/app/components/shadcn/chart/tooltip/component.rb +111 -0
  120. data/app/components/shadcn/checkbox/component.rb +109 -0
  121. data/app/components/shadcn/checkbox/preview.rb +16 -0
  122. data/app/components/shadcn/checkbox/previews/default.html.erb +14 -0
  123. data/app/components/shadcn/checkbox/previews/states.html.erb +23 -0
  124. data/app/components/shadcn/collapsible/component.rb +35 -0
  125. data/app/components/shadcn/collapsible/content/component.rb +20 -0
  126. data/app/components/shadcn/collapsible/preview.rb +11 -0
  127. data/app/components/shadcn/collapsible/previews/default.html.erb +9 -0
  128. data/app/components/shadcn/collapsible/trigger/component.rb +23 -0
  129. data/app/components/shadcn/combobox/chip/component.rb +60 -0
  130. data/app/components/shadcn/combobox/chip_input/component.rb +43 -0
  131. data/app/components/shadcn/combobox/chips/component.rb +54 -0
  132. data/app/components/shadcn/combobox/clear/component.rb +32 -0
  133. data/app/components/shadcn/combobox/component.rb +113 -0
  134. data/app/components/shadcn/combobox/content/component.rb +59 -0
  135. data/app/components/shadcn/combobox/empty/component.rb +23 -0
  136. data/app/components/shadcn/combobox/input/component.rb +85 -0
  137. data/app/components/shadcn/combobox/item/component.rb +69 -0
  138. data/app/components/shadcn/combobox/list/component.rb +27 -0
  139. data/app/components/shadcn/combobox/preview.rb +31 -0
  140. data/app/components/shadcn/combobox/previews/default.html.erb +20 -0
  141. data/app/components/shadcn/combobox/previews/grouped.html.erb +25 -0
  142. data/app/components/shadcn/combobox/previews/multiple.html.erb +28 -0
  143. data/app/components/shadcn/combobox/previews/with_clear.html.erb +16 -0
  144. data/app/components/shadcn/combobox/trigger/component.rb +54 -0
  145. data/app/components/shadcn/combobox.rb +16 -0
  146. data/app/components/shadcn/command/component.rb +67 -0
  147. data/app/components/shadcn/command/dialog/component.rb +72 -0
  148. data/app/components/shadcn/command/empty/component.rb +29 -0
  149. data/app/components/shadcn/command/group/component.rb +63 -0
  150. data/app/components/shadcn/command/input/component.rb +66 -0
  151. data/app/components/shadcn/command/item/component.rb +62 -0
  152. data/app/components/shadcn/command/list/component.rb +36 -0
  153. data/app/components/shadcn/command/preview.rb +26 -0
  154. data/app/components/shadcn/command/previews/default.html.erb +41 -0
  155. data/app/components/shadcn/command/previews/dialog.html.erb +33 -0
  156. data/app/components/shadcn/command/previews/ranking.html.erb +16 -0
  157. data/app/components/shadcn/command/separator/component.rb +33 -0
  158. data/app/components/shadcn/command.rb +14 -0
  159. data/app/components/shadcn/concerns/modal_root.rb +43 -0
  160. data/app/components/shadcn/concerns/submits_value.rb +81 -0
  161. data/app/components/shadcn/context_menu/component.rb +45 -0
  162. data/app/components/shadcn/context_menu/content/component.rb +40 -0
  163. data/app/components/shadcn/context_menu/preview.rb +16 -0
  164. data/app/components/shadcn/context_menu/previews/default.html.erb +34 -0
  165. data/app/components/shadcn/context_menu/previews/items.html.erb +35 -0
  166. data/app/components/shadcn/context_menu/sub/component.rb +47 -0
  167. data/app/components/shadcn/context_menu/sub_content/component.rb +31 -0
  168. data/app/components/shadcn/context_menu/trigger/component.rb +50 -0
  169. data/app/components/shadcn/context_menu.rb +43 -0
  170. data/app/components/shadcn/dialog/close/component.rb +20 -0
  171. data/app/components/shadcn/dialog/component.rb +20 -0
  172. data/app/components/shadcn/dialog/content/component.rb +78 -0
  173. data/app/components/shadcn/dialog/header/component.rb +23 -0
  174. data/app/components/shadcn/dialog/overlay/component.rb +28 -0
  175. data/app/components/shadcn/dialog/preview.rb +28 -0
  176. data/app/components/shadcn/dialog/previews/default.html.erb +17 -0
  177. data/app/components/shadcn/dialog/previews/inside_stacking_context.html.erb +25 -0
  178. data/app/components/shadcn/dialog/previews/no_close_button.html.erb +15 -0
  179. data/app/components/shadcn/dialog/previews/scrollable_content.html.erb +29 -0
  180. data/app/components/shadcn/dialog/trigger/component.rb +24 -0
  181. data/app/components/shadcn/dialog.rb +17 -0
  182. data/app/components/shadcn/drawer/component.rb +33 -0
  183. data/app/components/shadcn/drawer/content/component.rb +95 -0
  184. data/app/components/shadcn/drawer/header/component.rb +34 -0
  185. data/app/components/shadcn/drawer/preview.rb +29 -0
  186. data/app/components/shadcn/drawer/previews/default.html.erb +21 -0
  187. data/app/components/shadcn/drawer/previews/scrollable.html.erb +20 -0
  188. data/app/components/shadcn/drawer/previews/scrollable_side.html.erb +21 -0
  189. data/app/components/shadcn/drawer/previews/sides.html.erb +17 -0
  190. data/app/components/shadcn/drawer.rb +27 -0
  191. data/app/components/shadcn/dropdown_menu/checkbox_item/component.rb +61 -0
  192. data/app/components/shadcn/dropdown_menu/component.rb +48 -0
  193. data/app/components/shadcn/dropdown_menu/content/component.rb +49 -0
  194. data/app/components/shadcn/dropdown_menu/group/component.rb +16 -0
  195. data/app/components/shadcn/dropdown_menu/item/component.rb +50 -0
  196. data/app/components/shadcn/dropdown_menu/preview.rb +16 -0
  197. data/app/components/shadcn/dropdown_menu/previews/checkboxes_and_radio.html.erb +34 -0
  198. data/app/components/shadcn/dropdown_menu/previews/default.html.erb +23 -0
  199. data/app/components/shadcn/dropdown_menu/radio_group/component.rb +16 -0
  200. data/app/components/shadcn/dropdown_menu/radio_item/component.rb +63 -0
  201. data/app/components/shadcn/dropdown_menu/sub/component.rb +44 -0
  202. data/app/components/shadcn/dropdown_menu/sub_content/component.rb +45 -0
  203. data/app/components/shadcn/dropdown_menu/sub_trigger/component.rb +58 -0
  204. data/app/components/shadcn/dropdown_menu/trigger/component.rb +25 -0
  205. data/app/components/shadcn/dropdown_menu.rb +18 -0
  206. data/app/components/shadcn/empty/component.rb +17 -0
  207. data/app/components/shadcn/empty/media/component.rb +48 -0
  208. data/app/components/shadcn/empty/preview.rb +16 -0
  209. data/app/components/shadcn/empty/previews/default.html.erb +15 -0
  210. data/app/components/shadcn/empty/previews/media.html.erb +32 -0
  211. data/app/components/shadcn/empty.rb +26 -0
  212. data/app/components/shadcn/field/component.rb +61 -0
  213. data/app/components/shadcn/field/error/component.rb +49 -0
  214. data/app/components/shadcn/field/legend/component.rb +30 -0
  215. data/app/components/shadcn/field/preview.rb +17 -0
  216. data/app/components/shadcn/field/previews/default.html.erb +29 -0
  217. data/app/components/shadcn/field/previews/form_builder.html.erb +38 -0
  218. data/app/components/shadcn/field/separator/component.rb +35 -0
  219. data/app/components/shadcn/field.rb +44 -0
  220. data/app/components/shadcn/hover_card/component.rb +51 -0
  221. data/app/components/shadcn/hover_card/content/component.rb +45 -0
  222. data/app/components/shadcn/hover_card/preview.rb +16 -0
  223. data/app/components/shadcn/hover_card/previews/default.html.erb +36 -0
  224. data/app/components/shadcn/hover_card/previews/sides.html.erb +15 -0
  225. data/app/components/shadcn/hover_card/trigger/component.rb +33 -0
  226. data/app/components/shadcn/icon/component.rb +76 -0
  227. data/app/components/shadcn/icon.rb +43 -0
  228. data/app/components/shadcn/input/component.rb +26 -0
  229. data/app/components/shadcn/input/preview.rb +16 -0
  230. data/app/components/shadcn/input/previews/default.html.erb +5 -0
  231. data/app/components/shadcn/input/previews/states.html.erb +20 -0
  232. data/app/components/shadcn/input_group/addon/component.rb +65 -0
  233. data/app/components/shadcn/input_group/button/component.rb +40 -0
  234. data/app/components/shadcn/input_group/component.rb +43 -0
  235. data/app/components/shadcn/input_group/input/component.rb +25 -0
  236. data/app/components/shadcn/input_group/preview.rb +17 -0
  237. data/app/components/shadcn/input_group/previews/addons.html.erb +53 -0
  238. data/app/components/shadcn/input_group/previews/default.html.erb +25 -0
  239. data/app/components/shadcn/input_group/text/component.rb +20 -0
  240. data/app/components/shadcn/input_group/textarea/component.rb +21 -0
  241. data/app/components/shadcn/input_otp/component.rb +95 -0
  242. data/app/components/shadcn/input_otp/preview.rb +17 -0
  243. data/app/components/shadcn/input_otp/previews/default.html.erb +19 -0
  244. data/app/components/shadcn/input_otp/previews/variations.html.erb +33 -0
  245. data/app/components/shadcn/input_otp/separator/component.rb +21 -0
  246. data/app/components/shadcn/input_otp/slot/component.rb +60 -0
  247. data/app/components/shadcn/input_otp.rb +11 -0
  248. data/app/components/shadcn/item/component.rb +56 -0
  249. data/app/components/shadcn/item/group/component.rb +53 -0
  250. data/app/components/shadcn/item/media/component.rb +53 -0
  251. data/app/components/shadcn/item/preview.rb +20 -0
  252. data/app/components/shadcn/item/previews/default.html.erb +32 -0
  253. data/app/components/shadcn/item/previews/link.html.erb +39 -0
  254. data/app/components/shadcn/item/previews/outline.html.erb +8 -0
  255. data/app/components/shadcn/item/separator/component.rb +24 -0
  256. data/app/components/shadcn/item.rb +34 -0
  257. data/app/components/shadcn/kbd/component.rb +23 -0
  258. data/app/components/shadcn/kbd/group/component.rb +17 -0
  259. data/app/components/shadcn/kbd/preview.rb +11 -0
  260. data/app/components/shadcn/kbd/previews/default.html.erb +4 -0
  261. data/app/components/shadcn/label/component.rb +20 -0
  262. data/app/components/shadcn/label/preview.rb +11 -0
  263. data/app/components/shadcn/label/previews/default.html.erb +4 -0
  264. data/app/components/shadcn/marker/component.rb +46 -0
  265. data/app/components/shadcn/marker/icon/component.rb +23 -0
  266. data/app/components/shadcn/marker/preview.rb +17 -0
  267. data/app/components/shadcn/marker/previews/default.html.erb +18 -0
  268. data/app/components/shadcn/marker/previews/status.html.erb +32 -0
  269. data/app/components/shadcn/marker.rb +17 -0
  270. data/app/components/shadcn/menubar/component.rb +36 -0
  271. data/app/components/shadcn/menubar/content/component.rb +49 -0
  272. data/app/components/shadcn/menubar/menu/component.rb +39 -0
  273. data/app/components/shadcn/menubar/preview.rb +16 -0
  274. data/app/components/shadcn/menubar/previews/default.html.erb +52 -0
  275. data/app/components/shadcn/menubar/previews/items.html.erb +38 -0
  276. data/app/components/shadcn/menubar/sub/component.rb +43 -0
  277. data/app/components/shadcn/menubar/sub_content/component.rb +31 -0
  278. data/app/components/shadcn/menubar/sub_trigger/component.rb +31 -0
  279. data/app/components/shadcn/menubar/trigger/component.rb +41 -0
  280. data/app/components/shadcn/menubar.rb +47 -0
  281. data/app/components/shadcn/message/component.rb +33 -0
  282. data/app/components/shadcn/message/preview.rb +16 -0
  283. data/app/components/shadcn/message/previews/actions.html.erb +51 -0
  284. data/app/components/shadcn/message/previews/default.html.erb +35 -0
  285. data/app/components/shadcn/message.rb +35 -0
  286. data/app/components/shadcn/message_scroller/button/component.rb +78 -0
  287. data/app/components/shadcn/message_scroller/component.rb +59 -0
  288. data/app/components/shadcn/message_scroller/content/component.rb +50 -0
  289. data/app/components/shadcn/message_scroller/item/component.rb +45 -0
  290. data/app/components/shadcn/message_scroller/preview.rb +11 -0
  291. data/app/components/shadcn/message_scroller/previews/default.html.erb +72 -0
  292. data/app/components/shadcn/message_scroller/viewport/component.rb +41 -0
  293. data/app/components/shadcn/mode_switcher/component.rb +67 -0
  294. data/app/components/shadcn/mode_switcher/preview.rb +11 -0
  295. data/app/components/shadcn/mode_switcher/previews/default.html.erb +5 -0
  296. data/app/components/shadcn/mode_toggle/component.rb +75 -0
  297. data/app/components/shadcn/mode_toggle/preview.rb +11 -0
  298. data/app/components/shadcn/mode_toggle/previews/default.html.erb +20 -0
  299. data/app/components/shadcn/native_select/component.rb +59 -0
  300. data/app/components/shadcn/native_select/preview.rb +16 -0
  301. data/app/components/shadcn/native_select/previews/default.html.erb +9 -0
  302. data/app/components/shadcn/native_select/previews/groups.html.erb +26 -0
  303. data/app/components/shadcn/native_select.rb +16 -0
  304. data/app/components/shadcn/navigation_menu/component.rb +51 -0
  305. data/app/components/shadcn/navigation_menu/content/component.rb +58 -0
  306. data/app/components/shadcn/navigation_menu/preview.rb +11 -0
  307. data/app/components/shadcn/navigation_menu/previews/default.html.erb +49 -0
  308. data/app/components/shadcn/navigation_menu/trigger/component.rb +59 -0
  309. data/app/components/shadcn/navigation_menu.rb +28 -0
  310. data/app/components/shadcn/pagination/component.rb +19 -0
  311. data/app/components/shadcn/pagination/ellipsis/component.rb +28 -0
  312. data/app/components/shadcn/pagination/link/component.rb +37 -0
  313. data/app/components/shadcn/pagination/next/component.rb +31 -0
  314. data/app/components/shadcn/pagination/preview.rb +16 -0
  315. data/app/components/shadcn/pagination/previews/default.html.erb +18 -0
  316. data/app/components/shadcn/pagination/previews/simple.html.erb +39 -0
  317. data/app/components/shadcn/pagination/previous/component.rb +31 -0
  318. data/app/components/shadcn/pagination.rb +14 -0
  319. data/app/components/shadcn/parts.rb +38 -0
  320. data/app/components/shadcn/popover/anchor/component.rb +17 -0
  321. data/app/components/shadcn/popover/component.rb +42 -0
  322. data/app/components/shadcn/popover/content/component.rb +40 -0
  323. data/app/components/shadcn/popover/preview.rb +28 -0
  324. data/app/components/shadcn/popover/previews/align.html.erb +13 -0
  325. data/app/components/shadcn/popover/previews/default.html.erb +9 -0
  326. data/app/components/shadcn/popover/previews/inside_containing_block.html.erb +41 -0
  327. data/app/components/shadcn/popover/previews/inside_stacking_context.html.erb +30 -0
  328. data/app/components/shadcn/popover/trigger/component.rb +24 -0
  329. data/app/components/shadcn/popover.rb +16 -0
  330. data/app/components/shadcn/progress/component.rb +62 -0
  331. data/app/components/shadcn/progress/preview.rb +11 -0
  332. data/app/components/shadcn/progress/previews/default.html.erb +5 -0
  333. data/app/components/shadcn/radio_group/component.rb +51 -0
  334. data/app/components/shadcn/radio_group/item/component.rb +74 -0
  335. data/app/components/shadcn/radio_group/preview.rb +16 -0
  336. data/app/components/shadcn/radio_group/previews/default.html.erb +15 -0
  337. data/app/components/shadcn/radio_group/previews/states.html.erb +41 -0
  338. data/app/components/shadcn/resizable/component.rb +59 -0
  339. data/app/components/shadcn/resizable/handle/component.rb +70 -0
  340. data/app/components/shadcn/resizable/panel/component.rb +53 -0
  341. data/app/components/shadcn/resizable/preview.rb +23 -0
  342. data/app/components/shadcn/resizable/previews/default.html.erb +25 -0
  343. data/app/components/shadcn/resizable/previews/vertical.html.erb +19 -0
  344. data/app/components/shadcn/resizable/previews/with_handle.html.erb +17 -0
  345. data/app/components/shadcn/scroll_area/component.rb +84 -0
  346. data/app/components/shadcn/scroll_area/preview.rb +11 -0
  347. data/app/components/shadcn/scroll_area/previews/default.html.erb +36 -0
  348. data/app/components/shadcn/scroll_area/scrollbar/component.rb +72 -0
  349. data/app/components/shadcn/scroll_area/viewport/component.rb +70 -0
  350. data/app/components/shadcn/scroll_area.rb +14 -0
  351. data/app/components/shadcn/select/component.rb +67 -0
  352. data/app/components/shadcn/select/content/component.rb +157 -0
  353. data/app/components/shadcn/select/empty/component.rb +38 -0
  354. data/app/components/shadcn/select/group/component.rb +16 -0
  355. data/app/components/shadcn/select/item/component.rb +71 -0
  356. data/app/components/shadcn/select/list/component.rb +41 -0
  357. data/app/components/shadcn/select/preview.rb +24 -0
  358. data/app/components/shadcn/select/previews/default.html.erb +17 -0
  359. data/app/components/shadcn/select/previews/scrollable.html.erb +19 -0
  360. data/app/components/shadcn/select/previews/searchable.html.erb +18 -0
  361. data/app/components/shadcn/select/previews/states.html.erb +25 -0
  362. data/app/components/shadcn/select/search/component.rb +54 -0
  363. data/app/components/shadcn/select/trigger/component.rb +78 -0
  364. data/app/components/shadcn/select/value/component.rb +28 -0
  365. data/app/components/shadcn/select.rb +15 -0
  366. data/app/components/shadcn/separator/component.rb +41 -0
  367. data/app/components/shadcn/separator/preview.rb +11 -0
  368. data/app/components/shadcn/separator/previews/default.html.erb +9 -0
  369. data/app/components/shadcn/sheet/component.rb +16 -0
  370. data/app/components/shadcn/sheet/content/component.rb +98 -0
  371. data/app/components/shadcn/sheet/header/component.rb +23 -0
  372. data/app/components/shadcn/sheet/preview.rb +16 -0
  373. data/app/components/shadcn/sheet/previews/default.html.erb +16 -0
  374. data/app/components/shadcn/sheet/previews/no_close_button.html.erb +14 -0
  375. data/app/components/shadcn/sheet.rb +22 -0
  376. data/app/components/shadcn/sidebar/component.rb +232 -0
  377. data/app/components/shadcn/sidebar/input/component.rb +25 -0
  378. data/app/components/shadcn/sidebar/menu_action/component.rb +58 -0
  379. data/app/components/shadcn/sidebar/menu_button/component.rb +100 -0
  380. data/app/components/shadcn/sidebar/menu_skeleton/component.rb +51 -0
  381. data/app/components/shadcn/sidebar/menu_sub_button/component.rb +60 -0
  382. data/app/components/shadcn/sidebar/preview.rb +22 -0
  383. data/app/components/shadcn/sidebar/previews/default.html.erb +189 -0
  384. data/app/components/shadcn/sidebar/previews/floating.html.erb +39 -0
  385. data/app/components/shadcn/sidebar/previews/inset.html.erb +48 -0
  386. data/app/components/shadcn/sidebar/provider/component.rb +53 -0
  387. data/app/components/shadcn/sidebar/rail/component.rb +43 -0
  388. data/app/components/shadcn/sidebar/separator/component.rb +23 -0
  389. data/app/components/shadcn/sidebar/trigger/component.rb +39 -0
  390. data/app/components/shadcn/sidebar.rb +98 -0
  391. data/app/components/shadcn/skeleton/component.rb +14 -0
  392. data/app/components/shadcn/skeleton/preview.rb +11 -0
  393. data/app/components/shadcn/skeleton/previews/default.html.erb +7 -0
  394. data/app/components/shadcn/slider/component.rb +145 -0
  395. data/app/components/shadcn/slider/preview.rb +16 -0
  396. data/app/components/shadcn/slider/previews/default.html.erb +17 -0
  397. data/app/components/shadcn/slider/previews/vertical.html.erb +7 -0
  398. data/app/components/shadcn/slider/range/component.rb +51 -0
  399. data/app/components/shadcn/slider/thumb/component.rb +58 -0
  400. data/app/components/shadcn/slider/track/component.rb +32 -0
  401. data/app/components/shadcn/spinner/component.rb +21 -0
  402. data/app/components/shadcn/spinner/preview.rb +11 -0
  403. data/app/components/shadcn/spinner/previews/default.html.erb +7 -0
  404. data/app/components/shadcn/switch/component.rb +104 -0
  405. data/app/components/shadcn/switch/preview.rb +11 -0
  406. data/app/components/shadcn/switch/previews/default.html.erb +24 -0
  407. data/app/components/shadcn/table/component.rb +26 -0
  408. data/app/components/shadcn/table/preview.rb +16 -0
  409. data/app/components/shadcn/table/previews/default.html.erb +19 -0
  410. data/app/components/shadcn/table/previews/footer.html.erb +25 -0
  411. data/app/components/shadcn/table.rb +33 -0
  412. data/app/components/shadcn/tabs/component.rb +40 -0
  413. data/app/components/shadcn/tabs/content/component.rb +34 -0
  414. data/app/components/shadcn/tabs/list/component.rb +51 -0
  415. data/app/components/shadcn/tabs/preview.rb +16 -0
  416. data/app/components/shadcn/tabs/previews/default.html.erb +21 -0
  417. data/app/components/shadcn/tabs/previews/line.html.erb +23 -0
  418. data/app/components/shadcn/tabs/trigger/component.rb +66 -0
  419. data/app/components/shadcn/textarea/component.rb +24 -0
  420. data/app/components/shadcn/textarea/preview.rb +16 -0
  421. data/app/components/shadcn/textarea/previews/default.html.erb +3 -0
  422. data/app/components/shadcn/textarea/previews/states.html.erb +18 -0
  423. data/app/components/shadcn/theme_selector/component.rb +88 -0
  424. data/app/components/shadcn/theme_selector/preview.rb +17 -0
  425. data/app/components/shadcn/theme_selector/previews/all_themes.html.erb +20 -0
  426. data/app/components/shadcn/theme_selector/previews/default.html.erb +18 -0
  427. data/app/components/shadcn/toaster/component.rb +139 -0
  428. data/app/components/shadcn/toaster/preview.rb +16 -0
  429. data/app/components/shadcn/toaster/previews/default.html.erb +19 -0
  430. data/app/components/shadcn/toaster/previews/from_the_server.html.erb +15 -0
  431. data/app/components/shadcn/toaster/toast/component.rb +134 -0
  432. data/app/components/shadcn/toggle/component.rb +81 -0
  433. data/app/components/shadcn/toggle/preview.rb +11 -0
  434. data/app/components/shadcn/toggle/previews/default.html.erb +6 -0
  435. data/app/components/shadcn/toggle_group/component.rb +62 -0
  436. data/app/components/shadcn/toggle_group/item/component.rb +59 -0
  437. data/app/components/shadcn/toggle_group/preview.rb +16 -0
  438. data/app/components/shadcn/toggle_group/previews/default.html.erb +12 -0
  439. data/app/components/shadcn/toggle_group/previews/sizes.html.erb +37 -0
  440. data/app/components/shadcn/tooltip/component.rb +38 -0
  441. data/app/components/shadcn/tooltip/content/component.rb +62 -0
  442. data/app/components/shadcn/tooltip/preview.rb +21 -0
  443. data/app/components/shadcn/tooltip/previews/default.html.erb +4 -0
  444. data/app/components/shadcn/tooltip/previews/sides.html.erb +19 -0
  445. data/app/components/shadcn/tooltip/previews/with_keyboard_shortcut.html.erb +11 -0
  446. data/app/components/shadcn/tooltip/provider/component.rb +28 -0
  447. data/app/components/shadcn/tooltip/trigger/component.rb +25 -0
  448. data/app/helpers/shadcn_view_component/form_helper.rb +25 -0
  449. data/app/helpers/shadcn_view_component/theme_helper.rb +61 -0
  450. data/app/javascript/shadcn/animation.js +180 -0
  451. data/app/javascript/shadcn/command_score.js +110 -0
  452. data/app/javascript/shadcn/controllers/accordion_controller.js +127 -0
  453. data/app/javascript/shadcn/controllers/avatar_controller.js +32 -0
  454. data/app/javascript/shadcn/controllers/calendar_controller.js +633 -0
  455. data/app/javascript/shadcn/controllers/carousel_controller.js +139 -0
  456. data/app/javascript/shadcn/controllers/chart_controller.js +110 -0
  457. data/app/javascript/shadcn/controllers/checkbox_controller.js +61 -0
  458. data/app/javascript/shadcn/controllers/collapsible_controller.js +55 -0
  459. data/app/javascript/shadcn/controllers/combobox_controller.js +360 -0
  460. data/app/javascript/shadcn/controllers/command_controller.js +199 -0
  461. data/app/javascript/shadcn/controllers/dialog_controller.js +154 -0
  462. data/app/javascript/shadcn/controllers/drawer_controller.js +265 -0
  463. data/app/javascript/shadcn/controllers/dropdown_menu_controller.js +259 -0
  464. data/app/javascript/shadcn/controllers/hover_card_controller.js +85 -0
  465. data/app/javascript/shadcn/controllers/input_group_controller.js +17 -0
  466. data/app/javascript/shadcn/controllers/input_otp_controller.js +80 -0
  467. data/app/javascript/shadcn/controllers/menubar_controller.js +151 -0
  468. data/app/javascript/shadcn/controllers/message_scroller_controller.js +456 -0
  469. data/app/javascript/shadcn/controllers/navigation_menu_controller.js +211 -0
  470. data/app/javascript/shadcn/controllers/popover_controller.js +66 -0
  471. data/app/javascript/shadcn/controllers/radio_group_controller.js +69 -0
  472. data/app/javascript/shadcn/controllers/resizable_controller.js +167 -0
  473. data/app/javascript/shadcn/controllers/scroll_area_controller.js +253 -0
  474. data/app/javascript/shadcn/controllers/select_controller.js +320 -0
  475. data/app/javascript/shadcn/controllers/sidebar_controller.js +273 -0
  476. data/app/javascript/shadcn/controllers/slider_controller.js +230 -0
  477. data/app/javascript/shadcn/controllers/switch_controller.js +39 -0
  478. data/app/javascript/shadcn/controllers/tabs_controller.js +81 -0
  479. data/app/javascript/shadcn/controllers/theme_controller.js +67 -0
  480. data/app/javascript/shadcn/controllers/toaster_controller.js +281 -0
  481. data/app/javascript/shadcn/controllers/toggle_controller.js +24 -0
  482. data/app/javascript/shadcn/controllers/toggle_group_controller.js +73 -0
  483. data/app/javascript/shadcn/controllers/tooltip_controller.js +58 -0
  484. data/app/javascript/shadcn/direction.js +41 -0
  485. data/app/javascript/shadcn/dismiss.js +73 -0
  486. data/app/javascript/shadcn/floating.js +207 -0
  487. data/app/javascript/shadcn/focus.js +86 -0
  488. data/app/javascript/shadcn/id.js +14 -0
  489. data/app/javascript/shadcn/index.js +110 -0
  490. data/app/javascript/shadcn/popper.js +262 -0
  491. data/app/javascript/shadcn/scroll_geometry.js +267 -0
  492. data/app/javascript/shadcn/theme.js +161 -0
  493. data/app/javascript/shadcn/top_layer.js +59 -0
  494. data/app/javascript/shadcn/typeahead.js +99 -0
  495. data/config/importmap.rb +6 -0
  496. data/config/locales/en.yml +62 -0
  497. data/lib/generators/shadcn_view_component/install/install_generator.rb +149 -0
  498. data/lib/shadcn_view_component/engine.rb +73 -0
  499. data/lib/shadcn_view_component/form_builder.rb +264 -0
  500. data/lib/shadcn_view_component/icon_registry.rb +66 -0
  501. data/lib/shadcn_view_component/icons.rb +32 -0
  502. data/lib/shadcn_view_component/themes.rb +1065 -0
  503. data/lib/shadcn_view_component/version.rb +3 -0
  504. data/lib/shadcn_view_component.rb +82 -0
  505. data/lib/tasks/icons.rake +53 -0
  506. data/lib/tasks/themes.rake +171 -0
  507. metadata +608 -0
@@ -0,0 +1,456 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import {
3
+ getElementScrollTop,
4
+ getElementViewportTop,
5
+ getFirstVisibleMessageItem,
6
+ getFlexGap,
7
+ getMaxScrollTop,
8
+ getMessageScrollerItems,
9
+ getMessageScrollerScrollable,
10
+ getNewScrollAnchor,
11
+ getTailSpacerHeight,
12
+ getUnanchoredScrollAnchor,
13
+ hasMultipleNewScrollAnchors
14
+ } from "shadcn/scroll_geometry"
15
+
16
+ // A chat log that follows its own live end, ported from
17
+ // `vendor/shadcn-react/message-scroller/` — shadcn's own primitive rather than
18
+ // a Radix one, which is why this controller has a vendored source to answer to
19
+ // at all.
20
+ //
21
+ // Two of upstream's surfaces are deliberately absent, measured and argued in
22
+ // `.claude/docs/features/message-scroller.md`: the visibility store, and
23
+ // `scrollToMessage`.
24
+
25
+ // Upstream's `AUTOSCROLLING_CLEAR_DELAY` (types.ts:22): how long
26
+ // `data-autoscrolling` outlives a programmatic smooth scroll.
27
+ const AUTOSCROLLING_CLEAR_DELAY = 180
28
+ // Upstream's `SCROLL_POSITION_EPSILON` (types.ts:18). Sub-pixel scroll
29
+ // positions are normal, so "the same place" needs a tolerance.
30
+ const EPSILON = 0.5
31
+
32
+ export default class extends Controller {
33
+ static targets = [ "viewport", "content", "spacer", "button" ]
34
+ static values = {
35
+ autoScroll: { type: Boolean, default: false },
36
+ defaultScrollPosition: { type: String, default: "end" },
37
+ // The distance from an edge that still counts as being at it. Upstream
38
+ // leaves this undefined and falls back inside the primitive; the fallback
39
+ // is here instead, which is the only place it can be in a Stimulus value.
40
+ scrollEdgeThreshold: { type: Number, default: 8 },
41
+ scrollMargin: { type: Number, default: 0 },
42
+ // How much of the previous turn stays visible above an anchored one, so it
43
+ // reads as a continuation rather than as the top of the world.
44
+ scrollPreviousItemPeek: { type: Number, default: 64 },
45
+ // Upstream hangs this off the *viewport* (components.tsx:131). Here every
46
+ // option is a value on the root, because the element that would have
47
+ // carried them — the Provider — renders no DOM at all.
48
+ preserveScrollOnPrepend: { type: Boolean, default: true }
49
+ }
50
+
51
+ connect() {
52
+ if (!this.hasViewportTarget || !this.hasContentTarget) return
53
+
54
+ // `following-bottom` or `free-scrolling`. Upstream carries two more —
55
+ // `settling-jump` and `anchored-to-message` — which only the anchoring
56
+ // slice can enter.
57
+ this.mode = this.autoScrollValue ? "following-bottom" : "free-scrolling"
58
+ this.autoscrolling = false
59
+ this.lastScrollTop = 0
60
+ this.spacerHeight = 0
61
+ this.frame = null
62
+ this.autoscrollingTimer = null
63
+
64
+ // What a content change is measured against. `handledAnchors` is a WeakSet
65
+ // so an anchor removed from the DOM stops being remembered along with it.
66
+ const items = this.items()
67
+
68
+ this.itemCount = items.length
69
+ this.firstItem = items[0] ?? null
70
+ this.prependAnchor = null
71
+
72
+ // Anchors already in the markup count as handled, and this is the one place
73
+ // server rendering forces a difference from upstream rather than a
74
+ // translation of it. React mounts this component empty and fills it, so its
75
+ // first content change takes the `previousItemCount === 0` branch, goes to
76
+ // the end, and never jumps to an anchor that was there from the start.
77
+ // Measured on the live demo: at rest the tail spacer is hidden and the
78
+ // viewport sits at the very end, anchored last turn or not.
79
+ //
80
+ // Here the rows arrive with the document. Without this the first observer
81
+ // finds an unhandled anchor and takes the reader to it, so a conversation
82
+ // opens part-way up with a screenful of tail spacer below — which upstream
83
+ // never shows. `scroll_anchor` keeps its meaning for turns that *arrive*.
84
+ this.handledAnchors = new WeakSet()
85
+ for (const item of items) {
86
+ if (item.dataset.scrollAnchor === "true") this.handledAnchors.add(item)
87
+ }
88
+
89
+ this.onScroll = () => this.scheduleStateCommit()
90
+ this.viewportTarget.addEventListener("scroll", this.onScroll, { passive: true })
91
+
92
+ // Content changing is a *DOM* event here, exactly as it is upstream: the
93
+ // React version does not learn about new messages from re-rendering, it
94
+ // observes them (components.tsx). So the mechanism transfers unchanged —
95
+ // rows arriving, and rows growing as they stream.
96
+ this.mutations = new MutationObserver(() => this.handleContentChange())
97
+ this.mutations.observe(this.contentTarget, { childList: true, subtree: true, characterData: true })
98
+
99
+ this.resizes = new ResizeObserver(() => this.handleResize())
100
+ this.resizes.observe(this.contentTarget)
101
+ this.resizes.observe(this.viewportTarget)
102
+
103
+ this.applyDefaultScrollPosition()
104
+ this.commitScrollState()
105
+ this.capturePrependAnchor()
106
+ }
107
+
108
+ disconnect() {
109
+ this.viewportTarget?.removeEventListener("scroll", this.onScroll)
110
+ this.mutations?.disconnect()
111
+ this.resizes?.disconnect()
112
+ if (this.frame) cancelAnimationFrame(this.frame)
113
+ if (this.autoscrollingTimer) clearTimeout(this.autoscrollingTimer)
114
+ }
115
+
116
+ // What the two buttons call. Which end is its own `data-direction`, so one
117
+ // action serves both.
118
+ jump(event) {
119
+ const button = event.currentTarget
120
+
121
+ if (button.dataset.direction === "start") this.scrollToStart({ behavior: "smooth" })
122
+ else this.scrollToEnd({ behavior: "smooth" })
123
+ }
124
+
125
+ // ---------------------------------------------------------------- state
126
+
127
+ // `data-scrollable` and `data-autoscrolling` go on the root *and* the
128
+ // viewport, as upstream writes them (:107-131) — the viewport needs the
129
+ // second for `data-autoscrolling:scrollbar-none`, and the root carries both
130
+ // so a host can style from either.
131
+ writeStateAttributes(state) {
132
+ const scrollable = [ state.start && "start", state.end && "end" ].filter(Boolean).join(" ")
133
+
134
+ for (const element of [ this.element, this.viewportTarget ]) {
135
+ if (scrollable) element.setAttribute("data-scrollable", scrollable)
136
+ else element.removeAttribute("data-scrollable")
137
+
138
+ element.toggleAttribute("data-autoscrolling", this.autoscrolling)
139
+ }
140
+
141
+ if (this.hasButtonTarget) {
142
+ for (const button of this.buttonTargets) {
143
+ const active = button.dataset.direction === "start" ? state.start : state.end
144
+
145
+ button.dataset.active = String(active)
146
+ // In step with `data-active`, never behind it: the button is invisible
147
+ // and `pointer-events-none` while inactive, so a tab stop there is one
148
+ // a pointer does not have.
149
+ if (active) button.removeAttribute("tabindex")
150
+ else button.setAttribute("tabindex", "-1")
151
+ }
152
+ }
153
+ }
154
+
155
+ // The one place `mode` changes. Content growing past the live edge also reads
156
+ // as "not at the end", so following must only be released by the reader
157
+ // actually scrolling *up* — which is the only thing that lowers `scrollTop`.
158
+ reconcileFollowMode(scrollable) {
159
+ const scrollTop = this.viewportTarget.scrollTop
160
+ const scrolledUp = scrollTop < this.lastScrollTop - EPSILON
161
+
162
+ this.lastScrollTop = scrollTop
163
+
164
+ // Arming is suppressed while anchored or settling, and that is the subtle
165
+ // half. A freshly anchored turn sits above a tail spacer, which makes it
166
+ // read as "at the end" — re-arming there would let the first streamed chunk
167
+ // pull the reader off the row they were just taken to.
168
+ if (
169
+ this.autoScrollValue &&
170
+ !scrollable.end &&
171
+ this.mode !== "settling-jump" &&
172
+ this.mode !== "anchored-to-message"
173
+ ) {
174
+ this.mode = "following-bottom"
175
+ } else if (this.mode === "following-bottom" && scrollable.end && scrolledUp && !this.autoscrolling) {
176
+ this.mode = "free-scrolling"
177
+ }
178
+ }
179
+
180
+ commitScrollState() {
181
+ const state = this.scrollable()
182
+
183
+ this.reconcileFollowMode(state)
184
+
185
+ // While following, the scroller is already closing whatever gap a streamed
186
+ // chunk just opened, so publishing that gap would strobe the end button
187
+ // once per chunk. Reconcile runs on the raw geometry first, so a commit
188
+ // that *releases* following still publishes the gap it released over.
189
+ this.writeStateAttributes(
190
+ this.mode === "following-bottom" ? { ...state, end: false } : state
191
+ )
192
+ }
193
+
194
+ scheduleStateCommit() {
195
+ if (this.frame !== null) return
196
+
197
+ this.frame = requestAnimationFrame(() => {
198
+ this.frame = null
199
+ this.commitScrollState()
200
+ // Re-captured after every settled scroll, not only after a content
201
+ // change: the anchor is "the row you are looking at", and scrolling is
202
+ // what changes which row that is. Leaving it behind makes the next
203
+ // prepend correct by a delta measured from somewhere you have left —
204
+ // which is a wilder jump than doing nothing at all.
205
+ this.capturePrependAnchor()
206
+ })
207
+ }
208
+
209
+ scrollable() {
210
+ return getMessageScrollerScrollable({
211
+ content: this.contentTarget,
212
+ scrollEdgeThreshold: this.scrollEdgeThresholdValue,
213
+ spacer: this.spacer,
214
+ viewport: this.viewportTarget
215
+ })
216
+ }
217
+
218
+ // --------------------------------------------------------------- moving
219
+
220
+ // Set while a programmatic scroll is in flight and cleared on a timer, not on
221
+ // an event: `scrollend` is not everywhere yet, and the flag's job is to stop
222
+ // the animation's own scroll events from releasing follow mode.
223
+ setAutoScrolling(autoscrolling) {
224
+ if (this.autoscrollingTimer) {
225
+ clearTimeout(this.autoscrollingTimer)
226
+ this.autoscrollingTimer = null
227
+ }
228
+
229
+ if (this.autoscrolling !== autoscrolling) {
230
+ this.autoscrolling = autoscrolling
231
+ this.commitScrollState()
232
+ }
233
+
234
+ if (!autoscrolling) return
235
+
236
+ this.autoscrollingTimer = setTimeout(() => {
237
+ this.autoscrollingTimer = null
238
+ this.autoscrolling = false
239
+ this.commitScrollState()
240
+ }, AUTOSCROLLING_CLEAR_DELAY)
241
+ }
242
+
243
+ scrollToPosition(scrollTop, { behavior = "auto", autoscrolling = false } = {}) {
244
+ const next = Math.max(0, scrollTop)
245
+
246
+ // Already there: assign rather than animate, or a smooth scroll to where we
247
+ // stand never fires and the commit never comes.
248
+ if (Math.abs(this.viewportTarget.scrollTop - next) <= EPSILON) {
249
+ this.viewportTarget.scrollTop = next
250
+ this.commitScrollState()
251
+ return
252
+ }
253
+
254
+ if (autoscrolling) this.setAutoScrolling(true)
255
+
256
+ this.viewportTarget.scrollTo({ top: next, behavior })
257
+ this.scheduleStateCommit()
258
+ }
259
+
260
+ scrollToStart({ behavior = "auto" } = {}) {
261
+ this.setTailSpacerHeight(0)
262
+ this.mode = "free-scrolling"
263
+ this.scrollToPosition(0, { behavior })
264
+ }
265
+
266
+ scrollToEnd({ behavior = "auto" } = {}) {
267
+ this.setTailSpacerHeight(0)
268
+ this.mode = this.autoScrollValue ? "following-bottom" : "free-scrolling"
269
+ this.scrollToPosition(getMaxScrollTop(this.viewportTarget), { autoscrolling: true, behavior })
270
+ }
271
+
272
+ // The spacer under the last row, sized so that row can reach the *top* of the
273
+ // viewport instead of stopping at the bottom. The negative margin cancels the
274
+ // content column's own gap, which would otherwise be counted twice — once as
275
+ // the gap above the spacer and once as the height asked for here.
276
+ setTailSpacerHeight(height) {
277
+ if (!this.hasSpacerTarget) return
278
+
279
+ const next = Math.max(0, Math.ceil(height))
280
+ if (this.spacerHeight === next) return
281
+
282
+ this.spacerHeight = next
283
+ this.spacerTarget.hidden = next === 0
284
+ this.spacerTarget.style.height = `${next}px`
285
+ this.spacerTarget.style.marginTop = next > 0 ? `${-getFlexGap(this.contentTarget)}px` : ""
286
+ }
287
+
288
+ // Take a row to the top of the viewport, sizing the tail spacer so it can
289
+ // actually get there — without it, the last messages stop at the bottom.
290
+ // `keepPreviousPeek` leaves the previous turn showing above, which is what
291
+ // makes an arriving turn read as a continuation.
292
+ scrollToElement(element, { align = "start", behavior = "auto", keepPreviousPeek = false } = {}) {
293
+ if (!this.contentTarget.contains(element)) return false
294
+
295
+ const scrollMargin = keepPreviousPeek
296
+ ? this.scrollMarginValue + this.scrollPreviousItemPeekValue
297
+ : this.scrollMarginValue
298
+
299
+ const scrollTop = getElementScrollTop({
300
+ align, element, scrollMargin, spacer: this.spacer, viewport: this.viewportTarget
301
+ })
302
+
303
+ this.setTailSpacerHeight(this.tailSpacerFor(scrollTop))
304
+
305
+ // Seed the prepend anchor with the jump target, so a prepend landing before
306
+ // this scroll settles still preserves the row we were taken to.
307
+ this.prependAnchor = {
308
+ element,
309
+ viewportTop: getElementViewportTop(element, this.viewportTarget)
310
+ }
311
+
312
+ this.mode = keepPreviousPeek ? "anchored-to-message" : "settling-jump"
313
+ this.scrollToPosition(scrollTop, { behavior })
314
+
315
+ return true
316
+ }
317
+
318
+ // ------------------------------------------------------------- prepend
319
+
320
+ // The first visible row and where it sits *relative to the viewport*. That
321
+ // frame of reference is the whole trick: it is what survives rows being
322
+ // inserted above it.
323
+ capturePrependAnchor() {
324
+ const anchor = getFirstVisibleMessageItem({
325
+ content: this.contentTarget,
326
+ spacer: this.spacer,
327
+ viewport: this.viewportTarget
328
+ })
329
+
330
+ this.prependAnchor = anchor
331
+ ? { element: anchor, viewportTop: getElementViewportTop(anchor, this.viewportTarget) }
332
+ : null
333
+ }
334
+
335
+ // Correct the scroll by however far the remembered row moved. Where the
336
+ // browser's own scroll anchoring already handled the prepend the delta is
337
+ // zero and this does nothing — which is why it corrects a measurement rather
338
+ // than checking a capability flag, since engines misreport those.
339
+ restorePrependedAnchor() {
340
+ const anchor = this.prependAnchor
341
+
342
+ if (!anchor || !anchor.element.isConnected) return false
343
+
344
+ const delta = getElementViewportTop(anchor.element, this.viewportTarget) - anchor.viewportTop
345
+
346
+ if (Math.abs(delta) <= EPSILON) return false
347
+
348
+ this.viewportTarget.scrollTop += delta
349
+ anchor.viewportTop = getElementViewportTop(anchor.element, this.viewportTarget)
350
+ this.scheduleStateCommit()
351
+
352
+ return true
353
+ }
354
+
355
+ // ---------------------------------------------------------- reacting
356
+
357
+ // Branch order is load-bearing, and it is upstream's (:393-481): a prepend is
358
+ // not an append, and an arriving turn is not a row that merely grew.
359
+ handleContentChange() {
360
+ const items = this.items()
361
+ const previousCount = this.itemCount
362
+ const previousFirst = this.firstItem
363
+
364
+ this.itemCount = items.length
365
+ this.firstItem = items[0] ?? null
366
+
367
+ this.reconcileScrollPosition(items, previousCount, previousFirst)
368
+ this.capturePrependAnchor()
369
+ }
370
+
371
+ reconcileScrollPosition(items, previousCount, previousFirst) {
372
+ if (previousCount === 0) {
373
+ if (items.length > 0 && this.autoScrollValue) return this.scrollToEnd()
374
+
375
+ return this.scheduleStateCommit()
376
+ }
377
+
378
+ // Rows arrived *above* the ones already there, which is what loading older
379
+ // history looks like. Hold the view still rather than treating them as new.
380
+ const previousFirstIndex = previousFirst ? items.indexOf(previousFirst) : -1
381
+
382
+ if (this.preserveScrollOnPrependValue && previousFirstIndex > 0) {
383
+ return this.restorePrependedAnchor()
384
+ }
385
+
386
+ if (items.length > previousCount) {
387
+ const anchor = getNewScrollAnchor(items, previousCount)
388
+
389
+ if (anchor) {
390
+ // A batch of several anchored turns landing at once should keep
391
+ // following the end rather than yank back to anchor the first of them.
392
+ if (
393
+ this.autoScrollValue &&
394
+ this.mode === "following-bottom" &&
395
+ hasMultipleNewScrollAnchors(items, previousCount)
396
+ ) {
397
+ return this.scrollToEnd()
398
+ }
399
+
400
+ this.scrollToElement(anchor, { keepPreviousPeek: true })
401
+ this.handledAnchors.add(anchor)
402
+ return true
403
+ }
404
+ }
405
+
406
+ // Same rows, but one of them just became an anchor — a turn marked after it
407
+ // was already on the page.
408
+ if (items.length === previousCount) {
409
+ const anchor = getUnanchoredScrollAnchor(items, this.handledAnchors)
410
+
411
+ if (anchor) {
412
+ this.scrollToElement(anchor, { keepPreviousPeek: true })
413
+ this.handledAnchors.add(anchor)
414
+ return true
415
+ }
416
+ }
417
+
418
+ if (this.mode === "following-bottom" && this.autoScrollValue) return this.scrollToEnd()
419
+
420
+ return this.scheduleStateCommit()
421
+ }
422
+
423
+ items() {
424
+ return getMessageScrollerItems(this.contentTarget, this.spacer)
425
+ }
426
+
427
+ // A resize is not a content change: rows growing as they stream arrive here
428
+ // rather than through the mutation observer, and the answer is the same one.
429
+ handleResize() {
430
+ this.handleContentChange()
431
+ }
432
+
433
+ applyDefaultScrollPosition() {
434
+ if (this.defaultScrollPositionValue === "start") {
435
+ this.scrollToPosition(0)
436
+ return
437
+ }
438
+
439
+ this.scrollToPosition(getMaxScrollTop(this.viewportTarget))
440
+ }
441
+
442
+ // `hasSpacerTarget` guards every caller, so this can hand back null and let
443
+ // the geometry treat the column as spacer-less.
444
+ get spacer() {
445
+ return this.hasSpacerTarget ? this.spacerTarget : null
446
+ }
447
+
448
+ tailSpacerFor(scrollTop) {
449
+ return getTailSpacerHeight({
450
+ content: this.contentTarget,
451
+ scrollTop,
452
+ spacer: this.spacer,
453
+ viewport: this.viewportTarget
454
+ })
455
+ }
456
+ }
@@ -0,0 +1,211 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { pushLayer, removeLayer } from "shadcn/dismiss"
3
+ import { directionAwareKey, readDirection } from "shadcn/direction"
4
+
5
+ // Radix's NavigationMenu (vendor/radix/ui/navigation-menu.tsx), in the
6
+ // `viewport={false}` configuration — see the root component for why that is the
7
+ // only one this gem can reproduce.
8
+ //
9
+ // One panel is open at a time and it belongs to a trigger. Everything else here
10
+ // is about *when*: a menu that opened the instant a pointer crossed it would
11
+ // flash panels at anyone sweeping past, and one that always waited would feel
12
+ // stuck once you were already inside it.
13
+ export default class extends Controller {
14
+ static targets = [ "trigger", "content" ]
15
+ static values = {
16
+ // Radix's own (navigation-menu.tsx:136-137).
17
+ delay: { type: Number, default: 200 },
18
+ skipDelay: { type: Number, default: 300 }
19
+ }
20
+
21
+ connect() {
22
+ this.open = null
23
+ this.timer = null
24
+ this.skipTimer = null
25
+ // True for `skipDelay` after a panel closes: while it is, the next trigger
26
+ // opens with no wait at all. Sweeping across the row should feel like one
27
+ // gesture, not like several.
28
+ this.skipping = false
29
+ }
30
+
31
+ disconnect() {
32
+ this.cancel()
33
+ if (this.skipTimer) clearTimeout(this.skipTimer)
34
+ if (this.layer) removeLayer(this.layer)
35
+ }
36
+
37
+ // ------------------------------------------------------------- pointing
38
+
39
+ pointerEnter(event) {
40
+ if (event.pointerType === "touch") return
41
+
42
+ this.cancel()
43
+
44
+ const trigger = this.triggerFor(event.currentTarget)
45
+ if (!trigger || trigger === this.open) return
46
+
47
+ const wait = this.skipping || this.open ? 0 : this.delayValue
48
+ this.timer = setTimeout(() => this.show(trigger), wait)
49
+ }
50
+
51
+ // Leaving anywhere starts a close the next enter can cancel — which is what
52
+ // lets the pointer cross the gap between a trigger and its panel.
53
+ pointerLeave(event) {
54
+ if (event.pointerType === "touch") return
55
+
56
+ this.cancel()
57
+ this.timer = setTimeout(() => this.close(), this.delayValue)
58
+ }
59
+
60
+ // A tap has no hover to work with, so it toggles.
61
+ toggle(event) {
62
+ const trigger = this.triggerFor(event.currentTarget)
63
+ if (!trigger) return
64
+
65
+ event.preventDefault()
66
+ this.cancel()
67
+
68
+ if (trigger === this.open) this.close({ focusTrigger: true })
69
+ else this.show(trigger)
70
+ }
71
+
72
+ // ------------------------------------------------------------- keyboard
73
+
74
+ keydown(event) {
75
+ const key = directionAwareKey(event.key, readDirection(this.element))
76
+ const triggers = this.triggerTargets
77
+ const index = triggers.indexOf(event.currentTarget)
78
+
79
+ switch (key) {
80
+ case "ArrowRight":
81
+ event.preventDefault()
82
+ triggers[(index + 1) % triggers.length].focus()
83
+ return
84
+ case "ArrowLeft":
85
+ event.preventDefault()
86
+ triggers[(index - 1 + triggers.length) % triggers.length].focus()
87
+ return
88
+ case "Home":
89
+ event.preventDefault()
90
+ triggers[0].focus()
91
+ return
92
+ case "End":
93
+ event.preventDefault()
94
+ triggers[triggers.length - 1].focus()
95
+ return
96
+ // Down opens and steps into the panel, which is the only way a keyboard
97
+ // reaches the links: the panel is not in the tab order behind its
98
+ // trigger, it is under it.
99
+ case "ArrowDown":
100
+ event.preventDefault()
101
+ this.show(event.currentTarget)
102
+ this.focusFirstLink(event.currentTarget)
103
+ }
104
+ }
105
+
106
+ focusFirstLink(trigger) {
107
+ const link = this.contentFor(trigger)?.querySelector("[data-slot=navigation-menu-link]")
108
+
109
+ link?.focus()
110
+ }
111
+
112
+ // -------------------------------------------------------------- opening
113
+
114
+ show(trigger) {
115
+ if (trigger === this.open) return
116
+
117
+ const previous = this.open
118
+
119
+ this.hideContent(previous, this.motionBetween(previous, trigger, "to"))
120
+ this.open = trigger
121
+
122
+ trigger.dataset.state = "open"
123
+ trigger.setAttribute("aria-expanded", "true")
124
+
125
+ const content = this.contentFor(trigger)
126
+ if (content) {
127
+ content.dataset.motion = this.motionBetween(previous, trigger, "from")
128
+ content.hidden = false
129
+ content.dataset.state = "open"
130
+ }
131
+
132
+ if (this.layer) removeLayer(this.layer)
133
+ this.layer = pushLayer({
134
+ element: content || trigger,
135
+ anchors: this.triggerTargets,
136
+ // Escape and an outside click both arrive here, and only one of them
137
+ // should move focus: dismissing with a key leaves you where the keyboard
138
+ // can carry on, dismissing with a pointer leaves you where you clicked.
139
+ // Handled in the layer rather than in a listener of this controller's,
140
+ // because `dismiss.js` answers Escape first and a second path would find
141
+ // the menu already closed.
142
+ onDismiss: ({ reason }) => this.close({ focusTrigger: reason === "escape" })
143
+ })
144
+ }
145
+
146
+ close({ focusTrigger = false } = {}) {
147
+ const trigger = this.open
148
+ if (!trigger) return
149
+
150
+ this.hideContent(trigger, null)
151
+ this.open = null
152
+
153
+ if (this.layer) removeLayer(this.layer)
154
+ this.layer = null
155
+
156
+ if (focusTrigger) trigger.focus()
157
+
158
+ // The grace period. Anything entered inside it opens at once.
159
+ this.skipping = true
160
+ if (this.skipTimer) clearTimeout(this.skipTimer)
161
+ this.skipTimer = setTimeout(() => { this.skipping = false }, this.skipDelayValue)
162
+ }
163
+
164
+ hideContent(trigger, motion) {
165
+ if (!trigger) return
166
+
167
+ trigger.dataset.state = "closed"
168
+ trigger.setAttribute("aria-expanded", "false")
169
+
170
+ const content = this.contentFor(trigger)
171
+ if (!content) return
172
+
173
+ if (motion) content.dataset.motion = motion
174
+ content.dataset.state = "closed"
175
+ content.hidden = true
176
+ }
177
+
178
+ // Which side the panel comes from, so the pair slides rather than swapping.
179
+ // `null` when there is nothing to come from — a first open is not a move.
180
+ motionBetween(previous, next, prefix) {
181
+ if (!previous || previous === next) return null
182
+
183
+ const triggers = this.triggerTargets
184
+ const forward = triggers.indexOf(next) > triggers.indexOf(previous)
185
+
186
+ return forward ? `${prefix}-end` : `${prefix}-start`
187
+ }
188
+
189
+ // --------------------------------------------------------------- helpers
190
+
191
+ // A pointer event can arrive on a trigger or on a panel; both mean the same
192
+ // item.
193
+ triggerFor(element) {
194
+ if (this.triggerTargets.includes(element)) return element
195
+
196
+ const item = element.closest("[data-slot=navigation-menu-item]")
197
+
198
+ return this.triggerTargets.find((trigger) => item?.contains(trigger)) || null
199
+ }
200
+
201
+ contentFor(trigger) {
202
+ const item = trigger.closest("[data-slot=navigation-menu-item]")
203
+
204
+ return this.contentTargets.find((content) => item?.contains(content)) || null
205
+ }
206
+
207
+ cancel() {
208
+ if (this.timer) clearTimeout(this.timer)
209
+ this.timer = null
210
+ }
211
+ }