velvetkit 0.1.0.beta

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 (481) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +197 -0
  5. data/lib/generators/velvetkit/add/add_generator.rb +111 -0
  6. data/lib/generators/velvetkit/ai/ai_generator.rb +78 -0
  7. data/lib/generators/velvetkit/helpers.rb +64 -0
  8. data/lib/generators/velvetkit/install/install_generator.rb +108 -0
  9. data/lib/velvetkit/namespace.rb +57 -0
  10. data/lib/velvetkit/reference.rb +108 -0
  11. data/lib/velvetkit/registry.rb +162 -0
  12. data/lib/velvetkit/version.rb +5 -0
  13. data/lib/velvetkit.rb +28 -0
  14. data/registry/app/assets/tailwind/velvetkit.css +342 -0
  15. data/registry/app/components/ui/accordion/content.html.erb +5 -0
  16. data/registry/app/components/ui/accordion/content.rb +34 -0
  17. data/registry/app/components/ui/accordion/item.html.erb +1 -0
  18. data/registry/app/components/ui/accordion/item.rb +40 -0
  19. data/registry/app/components/ui/accordion/trigger.html.erb +1 -0
  20. data/registry/app/components/ui/accordion/trigger.rb +37 -0
  21. data/registry/app/components/ui/accordion.html.erb +7 -0
  22. data/registry/app/components/ui/accordion.rb +34 -0
  23. data/registry/app/components/ui/alert/description.html.erb +1 -0
  24. data/registry/app/components/ui/alert/description.rb +12 -0
  25. data/registry/app/components/ui/alert/title.html.erb +1 -0
  26. data/registry/app/components/ui/alert/title.rb +12 -0
  27. data/registry/app/components/ui/alert.html.erb +16 -0
  28. data/registry/app/components/ui/alert.rb +55 -0
  29. data/registry/app/components/ui/avatar/fallback.html.erb +1 -0
  30. data/registry/app/components/ui/avatar/fallback.rb +12 -0
  31. data/registry/app/components/ui/avatar/image.rb +20 -0
  32. data/registry/app/components/ui/avatar.html.erb +1 -0
  33. data/registry/app/components/ui/avatar.rb +56 -0
  34. data/registry/app/components/ui/avatar_group.html.erb +1 -0
  35. data/registry/app/components/ui/avatar_group.rb +35 -0
  36. data/registry/app/components/ui/badge.html.erb +1 -0
  37. data/registry/app/components/ui/badge.rb +38 -0
  38. data/registry/app/components/ui/banner.html.erb +10 -0
  39. data/registry/app/components/ui/banner.rb +47 -0
  40. data/registry/app/components/ui/base.rb +53 -0
  41. data/registry/app/components/ui/breadcrumb/auto_ellipsis.html.erb +23 -0
  42. data/registry/app/components/ui/breadcrumb/auto_ellipsis.rb +12 -0
  43. data/registry/app/components/ui/breadcrumb/ellipsis.html.erb +18 -0
  44. data/registry/app/components/ui/breadcrumb/ellipsis.rb +8 -0
  45. data/registry/app/components/ui/breadcrumb/item.html.erb +1 -0
  46. data/registry/app/components/ui/breadcrumb/item.rb +12 -0
  47. data/registry/app/components/ui/breadcrumb/item_slot.html.erb +10 -0
  48. data/registry/app/components/ui/breadcrumb/item_slot.rb +47 -0
  49. data/registry/app/components/ui/breadcrumb/list.html.erb +1 -0
  50. data/registry/app/components/ui/breadcrumb/list.rb +12 -0
  51. data/registry/app/components/ui/breadcrumb/separator.html.erb +1 -0
  52. data/registry/app/components/ui/breadcrumb/separator.rb +12 -0
  53. data/registry/app/components/ui/breadcrumb.html.erb +30 -0
  54. data/registry/app/components/ui/breadcrumb.rb +83 -0
  55. data/registry/app/components/ui/button.html.erb +1 -0
  56. data/registry/app/components/ui/button.rb +81 -0
  57. data/registry/app/components/ui/button_to.html.erb +1 -0
  58. data/registry/app/components/ui/button_to.rb +33 -0
  59. data/registry/app/components/ui/card/content.html.erb +1 -0
  60. data/registry/app/components/ui/card/content.rb +12 -0
  61. data/registry/app/components/ui/card/description.html.erb +1 -0
  62. data/registry/app/components/ui/card/description.rb +12 -0
  63. data/registry/app/components/ui/card/footer.html.erb +1 -0
  64. data/registry/app/components/ui/card/footer.rb +12 -0
  65. data/registry/app/components/ui/card/header.html.erb +1 -0
  66. data/registry/app/components/ui/card/header.rb +15 -0
  67. data/registry/app/components/ui/card/title.html.erb +1 -0
  68. data/registry/app/components/ui/card/title.rb +12 -0
  69. data/registry/app/components/ui/card.html.erb +1 -0
  70. data/registry/app/components/ui/card.rb +18 -0
  71. data/registry/app/components/ui/checkbox.html.erb +18 -0
  72. data/registry/app/components/ui/checkbox.rb +133 -0
  73. data/registry/app/components/ui/data_grid.html.erb +2 -0
  74. data/registry/app/components/ui/data_grid.rb +84 -0
  75. data/registry/app/components/ui/date_picker.html.erb +70 -0
  76. data/registry/app/components/ui/date_picker.rb +111 -0
  77. data/registry/app/components/ui/dialog/close.html.erb +7 -0
  78. data/registry/app/components/ui/dialog/close.rb +53 -0
  79. data/registry/app/components/ui/dialog/content.html.erb +26 -0
  80. data/registry/app/components/ui/dialog/content.rb +102 -0
  81. data/registry/app/components/ui/dialog/description.html.erb +1 -0
  82. data/registry/app/components/ui/dialog/description.rb +16 -0
  83. data/registry/app/components/ui/dialog/footer.html.erb +1 -0
  84. data/registry/app/components/ui/dialog/footer.rb +14 -0
  85. data/registry/app/components/ui/dialog/header.html.erb +1 -0
  86. data/registry/app/components/ui/dialog/header.rb +16 -0
  87. data/registry/app/components/ui/dialog/title.html.erb +1 -0
  88. data/registry/app/components/ui/dialog/title.rb +16 -0
  89. data/registry/app/components/ui/dialog/trigger.html.erb +5 -0
  90. data/registry/app/components/ui/dialog/trigger.rb +75 -0
  91. data/registry/app/components/ui/dialog.html.erb +3 -0
  92. data/registry/app/components/ui/dialog.rb +41 -0
  93. data/registry/app/components/ui/divider.html.erb +9 -0
  94. data/registry/app/components/ui/divider.rb +41 -0
  95. data/registry/app/components/ui/icon.html.erb +1 -0
  96. data/registry/app/components/ui/icon.rb +41 -0
  97. data/registry/app/components/ui/image.html.erb +1 -0
  98. data/registry/app/components/ui/image.rb +74 -0
  99. data/registry/app/components/ui/input.html.erb +9 -0
  100. data/registry/app/components/ui/input.rb +127 -0
  101. data/registry/app/components/ui/input_group/addon.html.erb +6 -0
  102. data/registry/app/components/ui/input_group/addon.rb +51 -0
  103. data/registry/app/components/ui/input_group/field.rb +26 -0
  104. data/registry/app/components/ui/input_group.html.erb +5 -0
  105. data/registry/app/components/ui/input_group.rb +55 -0
  106. data/registry/app/components/ui/label.html.erb +2 -0
  107. data/registry/app/components/ui/label.rb +48 -0
  108. data/registry/app/components/ui/link.html.erb +1 -0
  109. data/registry/app/components/ui/link.rb +64 -0
  110. data/registry/app/components/ui/menu/content.html.erb +5 -0
  111. data/registry/app/components/ui/menu/content.rb +106 -0
  112. data/registry/app/components/ui/menu/group.html.erb +1 -0
  113. data/registry/app/components/ui/menu/group.rb +19 -0
  114. data/registry/app/components/ui/menu/item.html.erb +1 -0
  115. data/registry/app/components/ui/menu/item.rb +68 -0
  116. data/registry/app/components/ui/menu/label.html.erb +1 -0
  117. data/registry/app/components/ui/menu/label.rb +33 -0
  118. data/registry/app/components/ui/menu/separator.html.erb +1 -0
  119. data/registry/app/components/ui/menu/separator.rb +20 -0
  120. data/registry/app/components/ui/menu/trigger.html.erb +1 -0
  121. data/registry/app/components/ui/menu/trigger.rb +29 -0
  122. data/registry/app/components/ui/menu.html.erb +3 -0
  123. data/registry/app/components/ui/menu.rb +23 -0
  124. data/registry/app/components/ui/native_select.html.erb +16 -0
  125. data/registry/app/components/ui/native_select.rb +109 -0
  126. data/registry/app/components/ui/pagination.html.erb +34 -0
  127. data/registry/app/components/ui/pagination.rb +62 -0
  128. data/registry/app/components/ui/password_input.html.erb +20 -0
  129. data/registry/app/components/ui/password_input.rb +94 -0
  130. data/registry/app/components/ui/popover/content.html.erb +12 -0
  131. data/registry/app/components/ui/popover/content.rb +83 -0
  132. data/registry/app/components/ui/popover/trigger.html.erb +1 -0
  133. data/registry/app/components/ui/popover/trigger.rb +29 -0
  134. data/registry/app/components/ui/popover.html.erb +3 -0
  135. data/registry/app/components/ui/popover.rb +24 -0
  136. data/registry/app/components/ui/radio_group/item.html.erb +18 -0
  137. data/registry/app/components/ui/radio_group/item.rb +97 -0
  138. data/registry/app/components/ui/radio_group.html.erb +1 -0
  139. data/registry/app/components/ui/radio_group.rb +35 -0
  140. data/registry/app/components/ui/select.html.erb +60 -0
  141. data/registry/app/components/ui/select.rb +181 -0
  142. data/registry/app/components/ui/sheet/close.rb +4 -0
  143. data/registry/app/components/ui/sheet/content.html.erb +24 -0
  144. data/registry/app/components/ui/sheet/content.rb +97 -0
  145. data/registry/app/components/ui/sheet/trigger.rb +4 -0
  146. data/registry/app/components/ui/sheet.html.erb +3 -0
  147. data/registry/app/components/ui/sheet.rb +37 -0
  148. data/registry/app/components/ui/sidebar/content.html.erb +1 -0
  149. data/registry/app/components/ui/sidebar/content.rb +18 -0
  150. data/registry/app/components/ui/sidebar/footer.html.erb +3 -0
  151. data/registry/app/components/ui/sidebar/footer.rb +16 -0
  152. data/registry/app/components/ui/sidebar/group.html.erb +1 -0
  153. data/registry/app/components/ui/sidebar/group.rb +19 -0
  154. data/registry/app/components/ui/sidebar/group_label.html.erb +1 -0
  155. data/registry/app/components/ui/sidebar/group_label.rb +18 -0
  156. data/registry/app/components/ui/sidebar/header.html.erb +1 -0
  157. data/registry/app/components/ui/sidebar/header.rb +16 -0
  158. data/registry/app/components/ui/sidebar/inset.html.erb +1 -0
  159. data/registry/app/components/ui/sidebar/inset.rb +17 -0
  160. data/registry/app/components/ui/sidebar/menu.html.erb +1 -0
  161. data/registry/app/components/ui/sidebar/menu.rb +18 -0
  162. data/registry/app/components/ui/sidebar/menu_button.html.erb +1 -0
  163. data/registry/app/components/ui/sidebar/menu_button.rb +30 -0
  164. data/registry/app/components/ui/sidebar/menu_item.html.erb +1 -0
  165. data/registry/app/components/ui/sidebar/menu_item.rb +23 -0
  166. data/registry/app/components/ui/sidebar/trigger.html.erb +1 -0
  167. data/registry/app/components/ui/sidebar/trigger.rb +24 -0
  168. data/registry/app/components/ui/sidebar/wrapper.html.erb +1 -0
  169. data/registry/app/components/ui/sidebar/wrapper.rb +40 -0
  170. data/registry/app/components/ui/sidebar.html.erb +11 -0
  171. data/registry/app/components/ui/sidebar.rb +29 -0
  172. data/registry/app/components/ui/spinner.html.erb +1 -0
  173. data/registry/app/components/ui/spinner.rb +20 -0
  174. data/registry/app/components/ui/switch.html.erb +12 -0
  175. data/registry/app/components/ui/switch.rb +109 -0
  176. data/registry/app/components/ui/table/cell.html.erb +1 -0
  177. data/registry/app/components/ui/table/cell.rb +16 -0
  178. data/registry/app/components/ui/table/column.html.erb +1 -0
  179. data/registry/app/components/ui/table/column.rb +18 -0
  180. data/registry/app/components/ui/table/head.html.erb +1 -0
  181. data/registry/app/components/ui/table/head.rb +13 -0
  182. data/registry/app/components/ui/table/row.html.erb +1 -0
  183. data/registry/app/components/ui/table/row.rb +26 -0
  184. data/registry/app/components/ui/table.html.erb +4 -0
  185. data/registry/app/components/ui/table.rb +45 -0
  186. data/registry/app/components/ui/tabs/content.html.erb +1 -0
  187. data/registry/app/components/ui/tabs/content.rb +43 -0
  188. data/registry/app/components/ui/tabs/list.html.erb +1 -0
  189. data/registry/app/components/ui/tabs/list.rb +28 -0
  190. data/registry/app/components/ui/tabs/trigger.html.erb +1 -0
  191. data/registry/app/components/ui/tabs/trigger.rb +91 -0
  192. data/registry/app/components/ui/tabs.html.erb +3 -0
  193. data/registry/app/components/ui/tabs.rb +27 -0
  194. data/registry/app/components/ui/text.html.erb +1 -0
  195. data/registry/app/components/ui/text.rb +48 -0
  196. data/registry/app/components/ui/theme_toggle.html.erb +9 -0
  197. data/registry/app/components/ui/theme_toggle.rb +63 -0
  198. data/registry/app/components/ui/tooltip/content.html.erb +1 -0
  199. data/registry/app/components/ui/tooltip/content.rb +65 -0
  200. data/registry/app/components/ui/tooltip/trigger.html.erb +1 -0
  201. data/registry/app/components/ui/tooltip/trigger.rb +33 -0
  202. data/registry/app/components/ui/tooltip.html.erb +3 -0
  203. data/registry/app/components/ui/tooltip.rb +24 -0
  204. data/registry/app/components/ui/typography/blockquote.html.erb +1 -0
  205. data/registry/app/components/ui/typography/blockquote.rb +15 -0
  206. data/registry/app/components/ui/typography/code.html.erb +1 -0
  207. data/registry/app/components/ui/typography/code.rb +25 -0
  208. data/registry/app/components/ui/typography/h1.rb +7 -0
  209. data/registry/app/components/ui/typography/h2.rb +7 -0
  210. data/registry/app/components/ui/typography/h3.rb +7 -0
  211. data/registry/app/components/ui/typography/h4.rb +7 -0
  212. data/registry/app/components/ui/typography/large.rb +7 -0
  213. data/registry/app/components/ui/typography/lead.rb +7 -0
  214. data/registry/app/components/ui/typography/muted.rb +7 -0
  215. data/registry/app/components/ui/typography/small.rb +7 -0
  216. data/registry/app/javascript/ag_grid.ts +26 -0
  217. data/registry/app/javascript/cally.ts +8 -0
  218. data/registry/app/javascript/controllers/ui/_internal/use_dismiss.ts +88 -0
  219. data/registry/app/javascript/controllers/ui/_internal/use_floating.ts +112 -0
  220. data/registry/app/javascript/controllers/ui/accordion_controller.ts +100 -0
  221. data/registry/app/javascript/controllers/ui/alert_controller.ts +7 -0
  222. data/registry/app/javascript/controllers/ui/banner_controller.ts +7 -0
  223. data/registry/app/javascript/controllers/ui/data_grid_controller.ts +198 -0
  224. data/registry/app/javascript/controllers/ui/date_picker_controller.ts +141 -0
  225. data/registry/app/javascript/controllers/ui/dialog_controller.ts +70 -0
  226. data/registry/app/javascript/controllers/ui/menu_controller.ts +201 -0
  227. data/registry/app/javascript/controllers/ui/password_visibility_controller.ts +43 -0
  228. data/registry/app/javascript/controllers/ui/popover_controller.ts +108 -0
  229. data/registry/app/javascript/controllers/ui/select_controller.ts +274 -0
  230. data/registry/app/javascript/controllers/ui/sidebar_controller.ts +64 -0
  231. data/registry/app/javascript/controllers/ui/tabs_controller.ts +74 -0
  232. data/registry/app/javascript/controllers/ui/theme_controller.ts +101 -0
  233. data/registry/app/javascript/controllers/ui/tooltip_controller.ts +102 -0
  234. data/registry/app/javascript/velvetkit_icons.ts +58 -0
  235. data/registry/app/views/layouts/_theme_script.html.erb +19 -0
  236. data/registry/components.yml +102 -0
  237. data/registry/config/locales/velvetkit.en.yml +56 -0
  238. data/registry/docs/agents.md +62 -0
  239. data/registry/test/components/previews/ui/accordion_preview/faq_style.html.erb +20 -0
  240. data/registry/test/components/previews/ui/accordion_preview/multiple.html.erb +27 -0
  241. data/registry/test/components/previews/ui/accordion_preview/single.html.erb +22 -0
  242. data/registry/test/components/previews/ui/accordion_preview.rb +31 -0
  243. data/registry/test/components/previews/ui/alert_preview/title_only.html.erb +12 -0
  244. data/registry/test/components/previews/ui/alert_preview/variants.html.erb +22 -0
  245. data/registry/test/components/previews/ui/alert_preview/with_icon.html.erb +12 -0
  246. data/registry/test/components/previews/ui/alert_preview/with_link.html.erb +18 -0
  247. data/registry/test/components/previews/ui/alert_preview.rb +34 -0
  248. data/registry/test/components/previews/ui/avatar_group_preview/default.html.erb +11 -0
  249. data/registry/test/components/previews/ui/avatar_group_preview/overflow.html.erb +23 -0
  250. data/registry/test/components/previews/ui/avatar_group_preview/small.html.erb +12 -0
  251. data/registry/test/components/previews/ui/avatar_group_preview.rb +23 -0
  252. data/registry/test/components/previews/ui/avatar_preview/colors.html.erb +8 -0
  253. data/registry/test/components/previews/ui/avatar_preview/shapes.html.erb +10 -0
  254. data/registry/test/components/previews/ui/avatar_preview/sizes.html.erb +8 -0
  255. data/registry/test/components/previews/ui/avatar_preview/with_image.html.erb +10 -0
  256. data/registry/test/components/previews/ui/avatar_preview.rb +24 -0
  257. data/registry/test/components/previews/ui/badge_preview/sizes.html.erb +5 -0
  258. data/registry/test/components/previews/ui/badge_preview/variants.html.erb +8 -0
  259. data/registry/test/components/previews/ui/badge_preview/with_icon.html.erb +14 -0
  260. data/registry/test/components/previews/ui/badge_preview.rb +20 -0
  261. data/registry/test/components/previews/ui/banner_preview/introduction.html.erb +9 -0
  262. data/registry/test/components/previews/ui/banner_preview/promotion.html.erb +9 -0
  263. data/registry/test/components/previews/ui/banner_preview/with_illustration.html.erb +12 -0
  264. data/registry/test/components/previews/ui/banner_preview.rb +25 -0
  265. data/registry/test/components/previews/ui/breadcrumb_preview/collapsed.html.erb +50 -0
  266. data/registry/test/components/previews/ui/breadcrumb_preview/default.html.erb +8 -0
  267. data/registry/test/components/previews/ui/breadcrumb_preview/long_path.html.erb +8 -0
  268. data/registry/test/components/previews/ui/breadcrumb_preview/medium.html.erb +7 -0
  269. data/registry/test/components/previews/ui/breadcrumb_preview/with_avatar.html.erb +8 -0
  270. data/registry/test/components/previews/ui/breadcrumb_preview.rb +66 -0
  271. data/registry/test/components/previews/ui/button_preview/disabled.html.erb +39 -0
  272. data/registry/test/components/previews/ui/button_preview/icon_only.html.erb +17 -0
  273. data/registry/test/components/previews/ui/button_preview/loading.html.erb +12 -0
  274. data/registry/test/components/previews/ui/button_preview/sizes.html.erb +6 -0
  275. data/registry/test/components/previews/ui/button_preview/variants.html.erb +8 -0
  276. data/registry/test/components/previews/ui/button_preview/with_both_icons.html.erb +12 -0
  277. data/registry/test/components/previews/ui/button_preview/with_leading_icon.html.erb +22 -0
  278. data/registry/test/components/previews/ui/button_preview/with_trailing_icon.html.erb +14 -0
  279. data/registry/test/components/previews/ui/button_preview.rb +65 -0
  280. data/registry/test/components/previews/ui/button_to_preview/states.html.erb +8 -0
  281. data/registry/test/components/previews/ui/button_to_preview/variants.html.erb +5 -0
  282. data/registry/test/components/previews/ui/button_to_preview/with_icon.html.erb +11 -0
  283. data/registry/test/components/previews/ui/button_to_preview.rb +21 -0
  284. data/registry/test/components/previews/ui/card_preview/complete.html.erb +31 -0
  285. data/registry/test/components/previews/ui/card_preview/default.html.erb +7 -0
  286. data/registry/test/components/previews/ui/card_preview/settings_form.html.erb +25 -0
  287. data/registry/test/components/previews/ui/card_preview/with_footer.html.erb +13 -0
  288. data/registry/test/components/previews/ui/card_preview/with_header.html.erb +11 -0
  289. data/registry/test/components/previews/ui/card_preview.rb +44 -0
  290. data/registry/test/components/previews/ui/checkbox_preview/in_form.html.erb +9 -0
  291. data/registry/test/components/previews/ui/checkbox_preview/states.html.erb +22 -0
  292. data/registry/test/components/previews/ui/checkbox_preview/with_label.html.erb +5 -0
  293. data/registry/test/components/previews/ui/checkbox_preview.rb +27 -0
  294. data/registry/test/components/previews/ui/data_grid_preview.rb +18 -0
  295. data/registry/test/components/previews/ui/date_picker_preview/default.html.erb +3 -0
  296. data/registry/test/components/previews/ui/date_picker_preview/states.html.erb +7 -0
  297. data/registry/test/components/previews/ui/date_picker_preview.rb +13 -0
  298. data/registry/test/components/previews/ui/dialog_preview/confirmation.html.erb +11 -0
  299. data/registry/test/components/previews/ui/dialog_preview/custom_trigger.html.erb +16 -0
  300. data/registry/test/components/previews/ui/dialog_preview/default.html.erb +15 -0
  301. data/registry/test/components/previews/ui/dialog_preview/long_content.html.erb +16 -0
  302. data/registry/test/components/previews/ui/dialog_preview/playground.html.erb +12 -0
  303. data/registry/test/components/previews/ui/dialog_preview/scrollable_body.html.erb +29 -0
  304. data/registry/test/components/previews/ui/dialog_preview/sizes.html.erb +46 -0
  305. data/registry/test/components/previews/ui/dialog_preview/without_close_button.html.erb +11 -0
  306. data/registry/test/components/previews/ui/dialog_preview/without_title.html.erb +13 -0
  307. data/registry/test/components/previews/ui/dialog_preview.rb +38 -0
  308. data/registry/test/components/previews/ui/divider_preview/horizontal.html.erb +5 -0
  309. data/registry/test/components/previews/ui/divider_preview/vertical.html.erb +9 -0
  310. data/registry/test/components/previews/ui/divider_preview/with_label.html.erb +5 -0
  311. data/registry/test/components/previews/ui/divider_preview.rb +19 -0
  312. data/registry/test/components/previews/ui/icon_preview/common.html.erb +8 -0
  313. data/registry/test/components/previews/ui/icon_preview/sizes.html.erb +7 -0
  314. data/registry/test/components/previews/ui/icon_preview.rb +20 -0
  315. data/registry/test/components/previews/ui/image_preview/radii.html.erb +8 -0
  316. data/registry/test/components/previews/ui/image_preview/sizing.html.erb +9 -0
  317. data/registry/test/components/previews/ui/image_preview.rb +16 -0
  318. data/registry/test/components/previews/ui/input_group_preview/addons.html.erb +29 -0
  319. data/registry/test/components/previews/ui/input_group_preview/playground.html.erb +12 -0
  320. data/registry/test/components/previews/ui/input_group_preview.rb +17 -0
  321. data/registry/test/components/previews/ui/input_preview/sizes.html.erb +5 -0
  322. data/registry/test/components/previews/ui/input_preview/states.html.erb +7 -0
  323. data/registry/test/components/previews/ui/input_preview/with_labels.html.erb +10 -0
  324. data/registry/test/components/previews/ui/input_preview.rb +33 -0
  325. data/registry/test/components/previews/ui/label_preview/sizes.html.erb +6 -0
  326. data/registry/test/components/previews/ui/label_preview/states.html.erb +6 -0
  327. data/registry/test/components/previews/ui/label_preview/weights.html.erb +5 -0
  328. data/registry/test/components/previews/ui/label_preview.rb +22 -0
  329. data/registry/test/components/previews/ui/link_preview/sizes.html.erb +5 -0
  330. data/registry/test/components/previews/ui/link_preview/variants.html.erb +8 -0
  331. data/registry/test/components/previews/ui/link_preview/with_icons.html.erb +11 -0
  332. data/registry/test/components/previews/ui/link_preview.rb +20 -0
  333. data/registry/test/components/previews/ui/menu_preview/positions.html.erb +122 -0
  334. data/registry/test/components/previews/ui/menu_preview/variants.html.erb +218 -0
  335. data/registry/test/components/previews/ui/menu_preview.rb +26 -0
  336. data/registry/test/components/previews/ui/native_select_preview/sizes.html.erb +6 -0
  337. data/registry/test/components/previews/ui/native_select_preview/states.html.erb +6 -0
  338. data/registry/test/components/previews/ui/native_select_preview.rb +23 -0
  339. data/registry/test/components/previews/ui/pagination_preview/states.html.erb +15 -0
  340. data/registry/test/components/previews/ui/pagination_preview.rb +15 -0
  341. data/registry/test/components/previews/ui/password_input_preview/states.html.erb +19 -0
  342. data/registry/test/components/previews/ui/password_input_preview.rb +21 -0
  343. data/registry/test/components/previews/ui/popover_preview/default.html.erb +7 -0
  344. data/registry/test/components/previews/ui/popover_preview/positions.html.erb +30 -0
  345. data/registry/test/components/previews/ui/popover_preview/with_close_button.html.erb +8 -0
  346. data/registry/test/components/previews/ui/popover_preview/with_rich_content.html.erb +17 -0
  347. data/registry/test/components/previews/ui/popover_preview/without_title.html.erb +9 -0
  348. data/registry/test/components/previews/ui/popover_preview.rb +32 -0
  349. data/registry/test/components/previews/ui/radio_group_preview/default.html.erb +10 -0
  350. data/registry/test/components/previews/ui/radio_group_preview/disabled.html.erb +17 -0
  351. data/registry/test/components/previews/ui/radio_group_preview/with_required.html.erb +10 -0
  352. data/registry/test/components/previews/ui/radio_group_preview.rb +25 -0
  353. data/registry/test/components/previews/ui/select_preview/default.html.erb +4 -0
  354. data/registry/test/components/previews/ui/select_preview/long_list.html.erb +5 -0
  355. data/registry/test/components/previews/ui/select_preview/multiple.html.erb +6 -0
  356. data/registry/test/components/previews/ui/select_preview/sizes.html.erb +5 -0
  357. data/registry/test/components/previews/ui/select_preview/states.html.erb +6 -0
  358. data/registry/test/components/previews/ui/select_preview.rb +45 -0
  359. data/registry/test/components/previews/ui/sheet_preview/bottom.html.erb +12 -0
  360. data/registry/test/components/previews/ui/sheet_preview/left.html.erb +10 -0
  361. data/registry/test/components/previews/ui/sheet_preview/playground.html.erb +15 -0
  362. data/registry/test/components/previews/ui/sheet_preview/right.html.erb +11 -0
  363. data/registry/test/components/previews/ui/sheet_preview/top.html.erb +8 -0
  364. data/registry/test/components/previews/ui/sheet_preview.rb +22 -0
  365. data/registry/test/components/previews/ui/sidebar_preview/_brand.html.erb +14 -0
  366. data/registry/test/components/previews/ui/sidebar_preview/_shell.html.erb +60 -0
  367. data/registry/test/components/previews/ui/sidebar_preview/collapsible.html.erb +55 -0
  368. data/registry/test/components/previews/ui/sidebar_preview/default.html.erb +17 -0
  369. data/registry/test/components/previews/ui/sidebar_preview/with_dropdown_header.html.erb +33 -0
  370. data/registry/test/components/previews/ui/sidebar_preview/with_groups.html.erb +22 -0
  371. data/registry/test/components/previews/ui/sidebar_preview.rb +15 -0
  372. data/registry/test/components/previews/ui/spinner_preview/in_buttons.html.erb +7 -0
  373. data/registry/test/components/previews/ui/spinner_preview/sizes.html.erb +7 -0
  374. data/registry/test/components/previews/ui/spinner_preview.rb +19 -0
  375. data/registry/test/components/previews/ui/switch_preview/default.html.erb +4 -0
  376. data/registry/test/components/previews/ui/switch_preview/sizes.html.erb +5 -0
  377. data/registry/test/components/previews/ui/switch_preview/states.html.erb +7 -0
  378. data/registry/test/components/previews/ui/switch_preview.rb +26 -0
  379. data/registry/test/components/previews/ui/table_preview/customers.html.erb +15 -0
  380. data/registry/test/components/previews/ui/table_preview/empty.html.erb +8 -0
  381. data/registry/test/components/previews/ui/table_preview/with_caption.html.erb +14 -0
  382. data/registry/test/components/previews/ui/table_preview.rb +12 -0
  383. data/registry/test/components/previews/ui/tabs_preview/default.html.erb +39 -0
  384. data/registry/test/components/previews/ui/tabs_preview/pill_variant.html.erb +18 -0
  385. data/registry/test/components/previews/ui/tabs_preview/settings_form.html.erb +42 -0
  386. data/registry/test/components/previews/ui/tabs_preview/with_icons.html.erb +34 -0
  387. data/registry/test/components/previews/ui/tabs_preview.rb +33 -0
  388. data/registry/test/components/previews/ui/text_preview/sizes.html.erb +10 -0
  389. data/registry/test/components/previews/ui/text_preview/tones.html.erb +5 -0
  390. data/registry/test/components/previews/ui/text_preview/weights.html.erb +6 -0
  391. data/registry/test/components/previews/ui/text_preview.rb +26 -0
  392. data/registry/test/components/previews/ui/theme_toggle_preview/in_a_header.html.erb +14 -0
  393. data/registry/test/components/previews/ui/theme_toggle_preview/palette.html.erb +47 -0
  394. data/registry/test/components/previews/ui/theme_toggle_preview.rb +15 -0
  395. data/registry/test/components/previews/ui/tooltip_preview/default.html.erb +8 -0
  396. data/registry/test/components/previews/ui/tooltip_preview/positions.html.erb +34 -0
  397. data/registry/test/components/previews/ui/tooltip_preview/with_button.html.erb +8 -0
  398. data/registry/test/components/previews/ui/tooltip_preview/with_icon.html.erb +10 -0
  399. data/registry/test/components/previews/ui/tooltip_preview.rb +29 -0
  400. data/registry/test/components/previews/ui/typography_preview/article.html.erb +11 -0
  401. data/registry/test/components/previews/ui/typography_preview/code_and_quotes.html.erb +6 -0
  402. data/registry/test/components/previews/ui/typography_preview/headings.html.erb +6 -0
  403. data/registry/test/components/previews/ui/typography_preview/text_styles.html.erb +7 -0
  404. data/registry/test/components/previews/ui/typography_preview.rb +23 -0
  405. data/registry/test/components/ui/accordion/content_test.rb +64 -0
  406. data/registry/test/components/ui/accordion/item_test.rb +61 -0
  407. data/registry/test/components/ui/accordion/trigger_test.rb +80 -0
  408. data/registry/test/components/ui/accordion_test.rb +96 -0
  409. data/registry/test/components/ui/alert/description_test.rb +37 -0
  410. data/registry/test/components/ui/alert/title_test.rb +38 -0
  411. data/registry/test/components/ui/alert_test.rb +184 -0
  412. data/registry/test/components/ui/avatar_group_test.rb +79 -0
  413. data/registry/test/components/ui/avatar_test.rb +129 -0
  414. data/registry/test/components/ui/badge_test.rb +112 -0
  415. data/registry/test/components/ui/banner_test.rb +116 -0
  416. data/registry/test/components/ui/base_test.rb +98 -0
  417. data/registry/test/components/ui/breadcrumb/ellipsis_test.rb +51 -0
  418. data/registry/test/components/ui/breadcrumb/item_test.rb +39 -0
  419. data/registry/test/components/ui/breadcrumb/list_test.rb +34 -0
  420. data/registry/test/components/ui/breadcrumb/separator_test.rb +36 -0
  421. data/registry/test/components/ui/breadcrumb_test.rb +276 -0
  422. data/registry/test/components/ui/button_test.rb +276 -0
  423. data/registry/test/components/ui/button_to_test.rb +74 -0
  424. data/registry/test/components/ui/card/content_test.rb +41 -0
  425. data/registry/test/components/ui/card/description_test.rb +32 -0
  426. data/registry/test/components/ui/card/footer_test.rb +34 -0
  427. data/registry/test/components/ui/card/header_test.rb +57 -0
  428. data/registry/test/components/ui/card/title_test.rb +34 -0
  429. data/registry/test/components/ui/card_test.rb +67 -0
  430. data/registry/test/components/ui/checkbox_test.rb +236 -0
  431. data/registry/test/components/ui/data_grid_test.rb +88 -0
  432. data/registry/test/components/ui/date_picker_test.rb +168 -0
  433. data/registry/test/components/ui/dialog/content_test.rb +156 -0
  434. data/registry/test/components/ui/dialog/trigger_test.rb +74 -0
  435. data/registry/test/components/ui/dialog_test.rb +54 -0
  436. data/registry/test/components/ui/divider_test.rb +69 -0
  437. data/registry/test/components/ui/icon_test.rb +82 -0
  438. data/registry/test/components/ui/image_test.rb +74 -0
  439. data/registry/test/components/ui/input_group_test.rb +143 -0
  440. data/registry/test/components/ui/input_test.rb +251 -0
  441. data/registry/test/components/ui/label_test.rb +96 -0
  442. data/registry/test/components/ui/link_test.rb +95 -0
  443. data/registry/test/components/ui/menu/content_test.rb +233 -0
  444. data/registry/test/components/ui/menu/group_test.rb +24 -0
  445. data/registry/test/components/ui/menu/item_test.rb +76 -0
  446. data/registry/test/components/ui/menu/label_test.rb +43 -0
  447. data/registry/test/components/ui/menu/separator_test.rb +32 -0
  448. data/registry/test/components/ui/menu/trigger_test.rb +109 -0
  449. data/registry/test/components/ui/menu_test.rb +152 -0
  450. data/registry/test/components/ui/native_select_test.rb +118 -0
  451. data/registry/test/components/ui/pagination_test.rb +92 -0
  452. data/registry/test/components/ui/password_input_test.rb +98 -0
  453. data/registry/test/components/ui/popover/content_test.rb +226 -0
  454. data/registry/test/components/ui/popover/trigger_test.rb +83 -0
  455. data/registry/test/components/ui/popover_test.rb +101 -0
  456. data/registry/test/components/ui/radio_group/item_test.rb +14 -0
  457. data/registry/test/components/ui/radio_group_test.rb +139 -0
  458. data/registry/test/components/ui/select_test.rb +181 -0
  459. data/registry/test/components/ui/sheet/content_test.rb +135 -0
  460. data/registry/test/components/ui/sheet/trigger_test.rb +23 -0
  461. data/registry/test/components/ui/sheet_test.rb +43 -0
  462. data/registry/test/components/ui/sidebar/menu_button_test.rb +43 -0
  463. data/registry/test/components/ui/sidebar/trigger_test.rb +17 -0
  464. data/registry/test/components/ui/sidebar/wrapper_test.rb +15 -0
  465. data/registry/test/components/ui/sidebar_test.rb +43 -0
  466. data/registry/test/components/ui/spinner_test.rb +63 -0
  467. data/registry/test/components/ui/switch_test.rb +206 -0
  468. data/registry/test/components/ui/table_test.rb +106 -0
  469. data/registry/test/components/ui/tabs/content_test.rb +93 -0
  470. data/registry/test/components/ui/tabs/list_test.rb +65 -0
  471. data/registry/test/components/ui/tabs/trigger_test.rb +159 -0
  472. data/registry/test/components/ui/tabs_test.rb +88 -0
  473. data/registry/test/components/ui/text_test.rb +84 -0
  474. data/registry/test/components/ui/theme_toggle_test.rb +88 -0
  475. data/registry/test/components/ui/tooltip/content_test.rb +135 -0
  476. data/registry/test/components/ui/tooltip/trigger_test.rb +52 -0
  477. data/registry/test/components/ui/tooltip_test.rb +97 -0
  478. data/registry/test/components/ui/typography/code_blockquote_test.rb +57 -0
  479. data/registry/test/components/ui/typography/heading_test.rb +71 -0
  480. data/registry/test/components/ui/typography/text_variants_test.rb +71 -0
  481. metadata +540 -0
@@ -0,0 +1,112 @@
1
+ // frozen_string_literal: true
2
+
3
+ import {
4
+ computePosition,
5
+ autoUpdate,
6
+ offset,
7
+ flip,
8
+ shift,
9
+ arrow,
10
+ type Placement,
11
+ type Middleware,
12
+ } from '@floating-ui/dom';
13
+
14
+ interface UseFloatingOptions {
15
+ placement?: Placement;
16
+ offset?: number;
17
+ flip?: boolean;
18
+ shift?: boolean;
19
+ arrow?: HTMLElement | null;
20
+ /** 'fixed' escapes scrolling or clipped ancestors, such as a drawer. */
21
+ strategy?: 'absolute' | 'fixed';
22
+ }
23
+
24
+ interface UseFloatingReturn {
25
+ update: () => void;
26
+ destroy: () => void;
27
+ }
28
+
29
+ const STATIC_SIDES: Record<string, string> = {
30
+ top: 'bottom',
31
+ right: 'left',
32
+ bottom: 'top',
33
+ left: 'right',
34
+ };
35
+
36
+ /**
37
+ * Floating UI wrapper for positioning floating elements relative to a reference.
38
+ * Used by Tooltip, Popover, and Dropdown components.
39
+ */
40
+ export function useFloating(
41
+ reference: HTMLElement,
42
+ floating: HTMLElement,
43
+ options: UseFloatingOptions = {}
44
+ ): UseFloatingReturn {
45
+ const {
46
+ placement = 'top',
47
+ offset: offsetValue = 8,
48
+ flip: enableFlip = true,
49
+ shift: enableShift = true,
50
+ arrow: arrowElement,
51
+ strategy = 'absolute',
52
+ } = options;
53
+
54
+ const middleware: Middleware[] = [];
55
+
56
+ if (offsetValue) {
57
+ middleware.push(offset(offsetValue));
58
+ }
59
+
60
+ if (enableFlip) {
61
+ middleware.push(flip());
62
+ }
63
+
64
+ if (enableShift) {
65
+ middleware.push(shift({ padding: 8 }));
66
+ }
67
+
68
+ if (arrowElement) {
69
+ middleware.push(arrow({ element: arrowElement }));
70
+ }
71
+
72
+ const cleanup = autoUpdate(reference, floating, () => {
73
+ computePosition(reference, floating, {
74
+ placement,
75
+ strategy,
76
+ middleware,
77
+ }).then(({ x, y, placement: finalPlacement, middlewareData }) => {
78
+ Object.assign(floating.style, {
79
+ left: `${x}px`,
80
+ top: `${y}px`,
81
+ position: strategy,
82
+ });
83
+
84
+ // Position arrow if present
85
+ if (arrowElement && middlewareData.arrow) {
86
+ const { x: arrowX, y: arrowY } = middlewareData.arrow;
87
+ const basePlacement = finalPlacement.split('-')[0];
88
+ const staticSide = basePlacement ? STATIC_SIDES[basePlacement] : undefined;
89
+
90
+ if (staticSide) {
91
+ // Clear the side a flip left behind, and tell the arrow which way
92
+ // it now points so its borders follow.
93
+ Object.assign(arrowElement.style, {
94
+ left: arrowX != null ? `${arrowX}px` : '',
95
+ top: arrowY != null ? `${arrowY}px` : '',
96
+ right: '',
97
+ bottom: '',
98
+ [staticSide]: '-4px',
99
+ });
100
+ arrowElement.dataset.side = basePlacement;
101
+ }
102
+ }
103
+ });
104
+ });
105
+
106
+ return {
107
+ update: () => {},
108
+ destroy: cleanup,
109
+ };
110
+ }
111
+
112
+ export type { Placement };
@@ -0,0 +1,100 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ export default class extends Controller<HTMLElement> {
4
+ static targets = ['item', 'trigger', 'content'];
5
+ static values = {
6
+ type: { type: String, default: 'single' },
7
+ values: { type: Array, default: [] },
8
+ };
9
+
10
+ declare readonly itemTargets: HTMLElement[];
11
+ declare readonly triggerTargets: HTMLButtonElement[];
12
+ declare readonly contentTargets: HTMLElement[];
13
+ declare typeValue: string;
14
+ declare valuesValue: string[];
15
+
16
+ connect() {
17
+ this.sync();
18
+ }
19
+
20
+ toggle(event: Event) {
21
+ const trigger = event.currentTarget as HTMLButtonElement;
22
+ const item = trigger.closest("[data-ui--accordion-target='item']") as HTMLElement;
23
+ if (!item) return;
24
+ const value = item.dataset.value!;
25
+ const open = !this.valuesValue.includes(value);
26
+
27
+ if (this.typeValue === 'single') {
28
+ if (open) {
29
+ this.valuesValue = [value];
30
+ } else {
31
+ this.valuesValue = [];
32
+ }
33
+ } else {
34
+ if (open) {
35
+ this.valuesValue = [...this.valuesValue, value];
36
+ } else {
37
+ this.valuesValue = this.valuesValue.filter((v) => v !== value);
38
+ }
39
+ }
40
+
41
+ this.sync();
42
+ }
43
+
44
+ keydown(event: KeyboardEvent) {
45
+ const enabled = this.triggerTargets.filter((t) => !t.disabled);
46
+ if (enabled.length === 0) return;
47
+ const currentIndex = enabled.indexOf(event.currentTarget as HTMLButtonElement);
48
+ let next = -1;
49
+
50
+ switch (event.key) {
51
+ case 'ArrowDown':
52
+ next = (currentIndex + 1) % enabled.length;
53
+ break;
54
+ case 'ArrowUp':
55
+ next = (currentIndex - 1 + enabled.length) % enabled.length;
56
+ break;
57
+ case 'Home':
58
+ next = 0;
59
+ break;
60
+ case 'End':
61
+ next = enabled.length - 1;
62
+ break;
63
+ default:
64
+ return;
65
+ }
66
+
67
+ event.preventDefault();
68
+ const target = enabled[next];
69
+ if (!target) return;
70
+ target.focus();
71
+ }
72
+
73
+ private sync() {
74
+ this.itemTargets.forEach((item) => {
75
+ const value = item.dataset.value!;
76
+ const open = this.valuesValue.includes(value);
77
+ item.dataset.state = open ? 'open' : 'closed';
78
+ });
79
+ this.triggerTargets.forEach((trigger) => {
80
+ const item = trigger.closest("[data-ui--accordion-target='item']") as HTMLElement;
81
+ if (!item) return;
82
+ const value = item.dataset.value!;
83
+ const open = this.valuesValue.includes(value);
84
+ trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
85
+ trigger.dataset.state = open ? 'open' : 'closed';
86
+ });
87
+ this.contentTargets.forEach((content) => {
88
+ const item = content.closest("[data-ui--accordion-target='item']") as HTMLElement;
89
+ if (!item) return;
90
+ const value = item.dataset.value!;
91
+ const open = this.valuesValue.includes(value);
92
+ content.dataset.state = open ? 'open' : 'closed';
93
+ if (open) {
94
+ content.removeAttribute('inert');
95
+ } else {
96
+ content.setAttribute('inert', '');
97
+ }
98
+ });
99
+ }
100
+ }
@@ -0,0 +1,7 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ export default class extends Controller<HTMLElement> {
4
+ dismiss() {
5
+ this.element.remove();
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ export default class extends Controller<HTMLElement> {
4
+ dismiss() {
5
+ this.element.remove();
6
+ }
7
+ }
@@ -0,0 +1,198 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // Connects to data-controller="ui--data-grid"
4
+ //
5
+ // The grid holds one page at a time. Each page — and each sort or filter the
6
+ // user applies — is a request to the server, which does the work in Postgres
7
+ // against the whole table. Filtering in the browser would only ever see the
8
+ // page the browser was sent.
9
+ //
10
+ // AG Grid is ~365 KB gzipped, several times the rest of the application, so it
11
+ // is built as its own entry point (app/javascript/ag_grid.ts) and imported at
12
+ // connect time. A page without a grid never downloads it.
13
+ //
14
+ // The URL is passed in from Ruby rather than written here, because Propshaft
15
+ // serves digested filenames. Importing a variable also stops the bundler
16
+ // resolving it statically and folding AG Grid back into application.js.
17
+ // turbo-rails installs Turbo on window from application.ts. Importing it here
18
+ // instead would bundle a second copy into this entry point, and two Turbo
19
+ // instances means two sets of event listeners on every navigation.
20
+ declare global {
21
+ interface Window {
22
+ Turbo?: { visit: (url: string) => void };
23
+ }
24
+ }
25
+
26
+ type ColumnSpec = {
27
+ field: string;
28
+ headerName: string;
29
+ width?: number;
30
+ type?: 'text' | 'number';
31
+ sortable: boolean;
32
+ filterable: boolean;
33
+ };
34
+
35
+ type GridRequest = {
36
+ startRow: number;
37
+ endRow: number;
38
+ sortModel: unknown[];
39
+ filterModel: Record<string, unknown>;
40
+ successCallback: (rows: unknown[], lastRow: number) => void;
41
+ failCallback: () => void;
42
+ };
43
+
44
+ export default class extends Controller<HTMLElement> {
45
+ static values = {
46
+ url: String,
47
+ columns: Array,
48
+ pageSize: { type: Number, default: 15 },
49
+ emptyMessage: { type: String, default: 'Nothing to show' },
50
+ moduleUrl: String,
51
+ };
52
+
53
+ declare readonly urlValue: string;
54
+ declare readonly columnsValue: ColumnSpec[];
55
+ declare readonly pageSizeValue: number;
56
+ declare readonly emptyMessageValue: string;
57
+ declare readonly moduleUrlValue: string;
58
+
59
+ private grid?: {
60
+ destroy: () => void;
61
+ setGridOption: (key: string, value: unknown) => void;
62
+ paginationGetPageSize: () => number;
63
+ };
64
+
65
+ async connect() {
66
+ const {
67
+ createGrid,
68
+ ModuleRegistry,
69
+ InfiniteRowModelModule,
70
+ PaginationModule,
71
+ TextFilterModule,
72
+ NumberFilterModule,
73
+ CustomFilterModule,
74
+ ColumnAutoSizeModule,
75
+ themeQuartz,
76
+ } = await import(this.moduleUrlValue);
77
+
78
+ ModuleRegistry.registerModules([
79
+ InfiniteRowModelModule,
80
+ PaginationModule,
81
+ TextFilterModule,
82
+ NumberFilterModule,
83
+ CustomFilterModule,
84
+ ColumnAutoSizeModule,
85
+ ]);
86
+
87
+ // The grid paints its own surface, so it is pointed at the app's design
88
+ // tokens rather than AG Grid's palette. Because these are the same CSS
89
+ // variables the rest of the page uses, switching to dark mode re-themes
90
+ // the grid with no JavaScript involved.
91
+ const theme = themeQuartz.withParams({
92
+ backgroundColor: 'var(--card)',
93
+ foregroundColor: 'var(--card-foreground)',
94
+ borderColor: 'var(--border)',
95
+ chromeBackgroundColor: 'var(--muted)',
96
+ // The header stays neutral; the primary colour shows in the sort and
97
+ // filter controls and in hover and selection, where it marks what the
98
+ // user is doing rather than tinting the whole chrome.
99
+ headerTextColor: 'var(--foreground)',
100
+ accentColor: 'var(--primary)',
101
+ rowHoverColor: 'color-mix(in oklab, var(--primary) 7%, transparent)',
102
+ selectedRowBackgroundColor: 'color-mix(in oklab, var(--primary) 12%, transparent)',
103
+ oddRowBackgroundColor: 'color-mix(in oklab, var(--muted-foreground) 6%, transparent)',
104
+ inputBackgroundColor: 'var(--background)',
105
+ inputBorder: { color: 'var(--input)' },
106
+ fontFamily: 'inherit',
107
+ fontSize: '14px',
108
+ headerFontSize: '14px',
109
+ headerFontWeight: 500,
110
+ wrapperBorderRadius: 'var(--radius-lg)',
111
+ borderRadius: 'var(--radius-sm)',
112
+ rowHeight: '36px',
113
+ cellFontFamily: 'inherit',
114
+ headerHeight: '36px',
115
+ cellHorizontalPadding: '12px',
116
+ });
117
+
118
+ this.grid = createGrid(this.element, {
119
+ theme,
120
+ columnDefs: this.columnsValue.map((column) => ({
121
+ field: column.field,
122
+ headerName: column.headerName,
123
+ width: column.width,
124
+ flex: column.width ? undefined : 1,
125
+ sortable: column.sortable,
126
+ filter: column.filterable
127
+ ? column.type === 'number'
128
+ ? 'agNumberColumnFilter'
129
+ : 'agTextColumnFilter'
130
+ : false,
131
+ // Every filter is a round trip, so it waits for a pause in typing
132
+ // rather than firing per keystroke.
133
+ filterParams: { debounceMs: 400, buttons: ['reset'] },
134
+ resizable: true,
135
+ })),
136
+ rowModelType: 'infinite',
137
+ datasource: { getRows: this.fetchRows.bind(this) },
138
+ // One request per page: the block the grid caches is the page it shows.
139
+ cacheBlockSize: this.pageSizeValue,
140
+ pagination: true,
141
+ paginationPageSize: this.pageSizeValue,
142
+ paginationPageSizeSelector: [15, 25, 50, 100],
143
+ // Changing the page size has to change the block size with it, or the
144
+ // grid asks for pages the server was never told about.
145
+ onPaginationChanged: () => {
146
+ const size = this.grid?.paginationGetPageSize();
147
+ if (size && size !== this.blockSize) {
148
+ this.blockSize = size;
149
+ this.grid?.setGridOption('cacheBlockSize', size);
150
+ }
151
+ },
152
+ overlayNoRowsTemplate: `<span class="text-sm text-muted-foreground">${this.emptyMessageValue}</span>`,
153
+ // A row is clickable when the server sent a path with it, so which
154
+ // screens open a record is decided in Ruby rather than here.
155
+ onRowClicked: (event: { data?: { _path?: string }; event?: Event }) => {
156
+ const path = event.data?._path;
157
+ if (!path) return;
158
+
159
+ // A click on a link or button inside a cell is that control's, not
160
+ // the row's.
161
+ const target = event.event?.target as HTMLElement | null;
162
+ if (target?.closest('a, button, input')) return;
163
+
164
+ window.Turbo?.visit(path);
165
+ },
166
+ });
167
+ }
168
+
169
+ disconnect() {
170
+ this.grid?.destroy();
171
+ this.grid = undefined;
172
+ }
173
+
174
+ private blockSize = 0;
175
+
176
+ private async fetchRows(params: GridRequest) {
177
+ const query = new URLSearchParams({
178
+ startRow: String(params.startRow),
179
+ endRow: String(params.endRow),
180
+ sortModel: JSON.stringify(params.sortModel),
181
+ filterModel: JSON.stringify(params.filterModel),
182
+ });
183
+
184
+ try {
185
+ const response = await fetch(`${this.urlValue}?${query}`, {
186
+ headers: { Accept: 'application/json' },
187
+ });
188
+ if (!response.ok) throw new Error(`${response.status}`);
189
+
190
+ const { rows, total } = await response.json();
191
+ // The total is what lets the grid show a page count rather than an
192
+ // endless scroll.
193
+ params.successCallback(rows, total);
194
+ } catch {
195
+ params.failCallback();
196
+ }
197
+ }
198
+ }
@@ -0,0 +1,141 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // Connects to data-controller="ui--date-picker"
4
+ //
5
+ // A typed field with a calendar beside it, over a hidden ISO input the form
6
+ // actually submits.
7
+ //
8
+ // Typed first, calendar second, because people who know a date type it in a
9
+ // second, and reaching a date years away by clicking back through a calendar
10
+ // takes month after month. The calendar is for dates you pick rather than know.
11
+ //
12
+ // The written order is not decided here. Ruby derives it from
13
+ // date.formats.typed in the locale and passes it in as "DMY" plus a
14
+ // separator, so one locale file governs what the field accepts, what it shows
15
+ // and what the hint says.
16
+ const ISO = /^(\d{4})-(\d{2})-(\d{2})$/;
17
+ // Any of / - . or a space between the parts: people type what is in front of
18
+ // them, and being strict about the separator only creates work.
19
+ const PARTS = /^(\d{1,4})[/\-. ](\d{1,4})[/\-. ](\d{1,4})$/;
20
+
21
+ export default class extends Controller<HTMLElement> {
22
+ static targets = ['input', 'hidden', 'calendar', 'panel', 'toggle'];
23
+ static values = {
24
+ moduleUrl: String,
25
+ order: { type: String, default: 'DMY' },
26
+ separator: { type: String, default: '/' },
27
+ };
28
+
29
+ declare readonly inputTarget: HTMLInputElement;
30
+ declare readonly hiddenTarget: HTMLInputElement;
31
+ declare readonly calendarTarget: HTMLElement;
32
+ declare readonly panelTarget: HTMLElement;
33
+ declare readonly toggleTarget: HTMLButtonElement;
34
+ declare readonly moduleUrlValue: string;
35
+ declare readonly orderValue: string;
36
+ declare readonly separatorValue: string;
37
+
38
+ private loaded = false;
39
+
40
+ connect() {
41
+ // The typed field is the source of truth on load, so a value already on
42
+ // the record shows in the locale's written format.
43
+ this.inputTarget.value = this.toTyped(this.hiddenTarget.value);
44
+ }
45
+
46
+ // Parsed on every keystroke rather than on blur, so a half-typed date simply
47
+ // clears the hidden field instead of submitting yesterday's value.
48
+ typed() {
49
+ const iso = this.toIso(this.inputTarget.value);
50
+ this.hiddenTarget.value = iso ?? '';
51
+ this.inputTarget.setAttribute('aria-invalid', this.invalid() ? 'true' : 'false');
52
+ if (iso && this.loaded) this.calendarTarget.setAttribute('value', iso);
53
+ }
54
+
55
+ async toggle(event: Event) {
56
+ event.preventDefault();
57
+ if (!this.panelTarget.hidden) return this.close();
58
+
59
+ await this.load();
60
+ this.panelTarget.hidden = false;
61
+ this.toggleTarget.setAttribute('aria-expanded', 'true');
62
+ (this.calendarTarget as HTMLElement & { focus?: () => void }).focus?.();
63
+ }
64
+
65
+ // Cally fires `change` with the chosen date on its own value attribute.
66
+ picked() {
67
+ const iso = (this.calendarTarget as HTMLElement & { value?: string }).value;
68
+ if (!iso) return;
69
+
70
+ this.hiddenTarget.value = iso;
71
+ this.inputTarget.value = this.toTyped(iso);
72
+ this.inputTarget.setAttribute('aria-invalid', 'false');
73
+ this.close();
74
+ this.inputTarget.focus();
75
+ }
76
+
77
+ close() {
78
+ this.panelTarget.hidden = true;
79
+ this.toggleTarget.setAttribute('aria-expanded', 'false');
80
+ }
81
+
82
+ // Clicking away and Escape both close it, which is what every other menu in
83
+ // the app does.
84
+ closeOnOutsideClick(event: Event) {
85
+ if (this.panelTarget.hidden) return;
86
+ if (this.element.contains(event.target as Node)) return;
87
+
88
+ this.close();
89
+ }
90
+
91
+ closeOnEscape(event: KeyboardEvent) {
92
+ if (event.key === 'Escape' && !this.panelTarget.hidden) {
93
+ this.close();
94
+ this.toggleTarget.focus();
95
+ }
96
+ }
97
+
98
+ private async load() {
99
+ if (this.loaded) return;
100
+
101
+ await import(this.moduleUrlValue);
102
+ this.loaded = true;
103
+ if (this.hiddenTarget.value) this.calendarTarget.setAttribute('value', this.hiddenTarget.value);
104
+ }
105
+
106
+ private invalid() {
107
+ const typed = this.inputTarget.value.trim();
108
+ return typed !== '' && this.toIso(typed) === null;
109
+ }
110
+
111
+ // The locale's order in, ISO out. Rejects 31/02 rather than rolling it into
112
+ // the 3rd of March, which is what Date does and how a wrong birth date gets
113
+ // stored without anyone seeing an error.
114
+ private toIso(typed: string): string | null {
115
+ const match = PARTS.exec(typed.trim());
116
+ if (!match) return null;
117
+
118
+ const order = this.orderValue.split('');
119
+ const day = match[order.indexOf('D') + 1];
120
+ const month = match[order.indexOf('M') + 1];
121
+ const year = match[order.indexOf('Y') + 1];
122
+ if (!day || !month || !year || year.length !== 4) return null;
123
+
124
+ const iso = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
125
+ const date = new Date(`${iso}T00:00:00`);
126
+ if (Number.isNaN(date.getTime())) return null;
127
+
128
+ // Round-tripped, because Date accepts the 31st of February and quietly
129
+ // returns the 3rd of March.
130
+ return date.getDate() === Number(day) && date.getMonth() + 1 === Number(month) ? iso : null;
131
+ }
132
+
133
+ private toTyped(iso: string): string {
134
+ const match = ISO.exec((iso ?? '').trim());
135
+ if (!match) return '';
136
+
137
+ const [, year, month, day] = match;
138
+ const parts: Record<string, string> = { D: day, M: month, Y: year };
139
+ return this.orderValue.split('').map((key) => parts[key]).join(this.separatorValue);
140
+ }
141
+ }
@@ -0,0 +1,70 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * UI::Dialog controller — opens and closes a native <dialog> via showModal().
5
+ *
6
+ * What we DO:
7
+ * - open() : trigger.click → dialog.showModal() → set data-state=open
8
+ * - close() : close-button.click or backdrop click → dialog.close()
9
+ *
10
+ * What we DELEGATE TO THE PLATFORM:
11
+ * - modal mode, inert background, return focus, focus trap, ESC dismissal —
12
+ * all handled by <dialog>.showModal() natively.
13
+ */
14
+ export default class extends Controller<HTMLElement> {
15
+ static targets = ['trigger', 'content'];
16
+
17
+ declare readonly hasTriggerTarget: boolean;
18
+ declare readonly triggerTarget: HTMLElement;
19
+ declare readonly contentTarget: HTMLDialogElement;
20
+
21
+ connect() {
22
+ this.contentTarget.addEventListener('close', this.handleClose);
23
+ this.contentTarget.addEventListener('cancel', this.handleCancel);
24
+ this.contentTarget.addEventListener('click', this.handleBackdropClick);
25
+ }
26
+
27
+ disconnect() {
28
+ this.contentTarget.removeEventListener('close', this.handleClose);
29
+ this.contentTarget.removeEventListener('cancel', this.handleCancel);
30
+ this.contentTarget.removeEventListener('click', this.handleBackdropClick);
31
+ if (this.contentTarget.open) this.contentTarget.close();
32
+ }
33
+
34
+ open(event?: Event) {
35
+ event?.preventDefault();
36
+ if (this.contentTarget.open) return;
37
+ this.contentTarget.showModal();
38
+ this.setState('open');
39
+ }
40
+
41
+ close(event?: Event) {
42
+ event?.preventDefault();
43
+ if (this.contentTarget.open) this.contentTarget.close();
44
+ }
45
+
46
+ // Native `cancel` fires on ESC. Let the default close happen, just sync
47
+ // state via the close listener.
48
+ private handleCancel = (_event: Event) => {
49
+ /* noop — close listener handles state */
50
+ };
51
+
52
+ private handleClose = () => {
53
+ this.setState('closed');
54
+ };
55
+
56
+ // Click outside the panel = click on the dialog element itself (because the
57
+ // panel is a child of the dialog). Close in that case.
58
+ private handleBackdropClick = (event: MouseEvent) => {
59
+ if (event.target === this.contentTarget) this.close();
60
+ };
61
+
62
+ private setState(state: 'open' | 'closed') {
63
+ this.contentTarget.dataset.state = state;
64
+ const panel = this.contentTarget.querySelector<HTMLElement>('[data-ui--dialog-part="panel"]');
65
+ if (panel) panel.dataset.state = state;
66
+ if (this.hasTriggerTarget) {
67
+ this.triggerTarget.setAttribute('aria-expanded', state === 'open' ? 'true' : 'false');
68
+ }
69
+ }
70
+ }