poetry-ui 0.0.2

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 (442) hide show
  1. checksums.yaml +7 -0
  2. data/.gitattributes +6 -0
  3. data/CHANGELOG.md +3 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +87 -0
  6. data/THIRD_PARTY_NOTICES.md +201 -0
  7. data/app/components/poetry/ui/accordion/component.html.erb +3 -0
  8. data/app/components/poetry/ui/accordion/component.rb +197 -0
  9. data/app/components/poetry/ui/accordion/preview.rb +39 -0
  10. data/app/components/poetry/ui/accordion/style.rb +37 -0
  11. data/app/components/poetry/ui/alert/component.html.erb +6 -0
  12. data/app/components/poetry/ui/alert/component.rb +61 -0
  13. data/app/components/poetry/ui/alert/preview.rb +35 -0
  14. data/app/components/poetry/ui/alert/style.rb +24 -0
  15. data/app/components/poetry/ui/alert_dialog/component.html.erb +17 -0
  16. data/app/components/poetry/ui/alert_dialog/component.rb +208 -0
  17. data/app/components/poetry/ui/alert_dialog/preview.rb +48 -0
  18. data/app/components/poetry/ui/alert_dialog/style.rb +46 -0
  19. data/app/components/poetry/ui/aspect_ratio/component.rb +63 -0
  20. data/app/components/poetry/ui/aspect_ratio/preview.rb +29 -0
  21. data/app/components/poetry/ui/aspect_ratio/style.rb +13 -0
  22. data/app/components/poetry/ui/attachment/component.html.erb +13 -0
  23. data/app/components/poetry/ui/attachment/component.rb +139 -0
  24. data/app/components/poetry/ui/attachment/preview.rb +83 -0
  25. data/app/components/poetry/ui/attachment/style.rb +59 -0
  26. data/app/components/poetry/ui/autocomplete/component.html.erb +16 -0
  27. data/app/components/poetry/ui/autocomplete/component.rb +193 -0
  28. data/app/components/poetry/ui/autocomplete/preview.rb +39 -0
  29. data/app/components/poetry/ui/autocomplete/style.rb +18 -0
  30. data/app/components/poetry/ui/avatar/component.rb +103 -0
  31. data/app/components/poetry/ui/avatar/group_preview.html.erb +11 -0
  32. data/app/components/poetry/ui/avatar/preview.rb +50 -0
  33. data/app/components/poetry/ui/avatar/style.rb +34 -0
  34. data/app/components/poetry/ui/badge/component.rb +73 -0
  35. data/app/components/poetry/ui/badge/preview.rb +54 -0
  36. data/app/components/poetry/ui/badge/style.rb +35 -0
  37. data/app/components/poetry/ui/breadcrumb/component.html.erb +32 -0
  38. data/app/components/poetry/ui/breadcrumb/component.rb +102 -0
  39. data/app/components/poetry/ui/breadcrumb/dropdown_crumb_preview.html.erb +21 -0
  40. data/app/components/poetry/ui/breadcrumb/preview.rb +45 -0
  41. data/app/components/poetry/ui/breadcrumb/style.rb +25 -0
  42. data/app/components/poetry/ui/bubble/component.html.erb +4 -0
  43. data/app/components/poetry/ui/bubble/component.rb +106 -0
  44. data/app/components/poetry/ui/bubble/preview.rb +62 -0
  45. data/app/components/poetry/ui/bubble/style.rb +47 -0
  46. data/app/components/poetry/ui/button/component.html.erb +14 -0
  47. data/app/components/poetry/ui/button/component.rb +168 -0
  48. data/app/components/poetry/ui/button/preview.rb +94 -0
  49. data/app/components/poetry/ui/button/style.rb +41 -0
  50. data/app/components/poetry/ui/button_group/component.rb +70 -0
  51. data/app/components/poetry/ui/button_group/popup_triggers_preview.html.erb +21 -0
  52. data/app/components/poetry/ui/button_group/preview.rb +46 -0
  53. data/app/components/poetry/ui/button_group/style.rb +27 -0
  54. data/app/components/poetry/ui/button_group/text_prefix_preview.html.erb +7 -0
  55. data/app/components/poetry/ui/calendar/component.html.erb +72 -0
  56. data/app/components/poetry/ui/calendar/component.rb +366 -0
  57. data/app/components/poetry/ui/calendar/preview.rb +55 -0
  58. data/app/components/poetry/ui/calendar/style.rb +55 -0
  59. data/app/components/poetry/ui/card/component.html.erb +11 -0
  60. data/app/components/poetry/ui/card/component.rb +90 -0
  61. data/app/components/poetry/ui/card/preview.rb +29 -0
  62. data/app/components/poetry/ui/card/style.rb +23 -0
  63. data/app/components/poetry/ui/carousel/component.html.erb +17 -0
  64. data/app/components/poetry/ui/carousel/component.rb +179 -0
  65. data/app/components/poetry/ui/carousel/preview.rb +67 -0
  66. data/app/components/poetry/ui/carousel/style.rb +39 -0
  67. data/app/components/poetry/ui/checkbox/component.html.erb +22 -0
  68. data/app/components/poetry/ui/checkbox/component.rb +179 -0
  69. data/app/components/poetry/ui/checkbox/preview.rb +68 -0
  70. data/app/components/poetry/ui/checkbox/style.rb +28 -0
  71. data/app/components/poetry/ui/clipboard_text/component.html.erb +15 -0
  72. data/app/components/poetry/ui/clipboard_text/component.rb +154 -0
  73. data/app/components/poetry/ui/clipboard_text/preview.rb +24 -0
  74. data/app/components/poetry/ui/clipboard_text/style.rb +23 -0
  75. data/app/components/poetry/ui/code_block/component.html.erb +13 -0
  76. data/app/components/poetry/ui/code_block/component.rb +141 -0
  77. data/app/components/poetry/ui/code_block/preview.rb +40 -0
  78. data/app/components/poetry/ui/code_block/style.rb +38 -0
  79. data/app/components/poetry/ui/collapsible/component.html.erb +4 -0
  80. data/app/components/poetry/ui/collapsible/component.rb +134 -0
  81. data/app/components/poetry/ui/collapsible/preview.rb +24 -0
  82. data/app/components/poetry/ui/combobox/component.html.erb +49 -0
  83. data/app/components/poetry/ui/combobox/component.rb +1086 -0
  84. data/app/components/poetry/ui/combobox/preview.rb +164 -0
  85. data/app/components/poetry/ui/combobox/style.rb +148 -0
  86. data/app/components/poetry/ui/command/component.html.erb +20 -0
  87. data/app/components/poetry/ui/command/component.rb +526 -0
  88. data/app/components/poetry/ui/command/dialog_component.html.erb +14 -0
  89. data/app/components/poetry/ui/command/dialog_component.rb +206 -0
  90. data/app/components/poetry/ui/command/dialog_preview.rb +39 -0
  91. data/app/components/poetry/ui/command/preview.rb +121 -0
  92. data/app/components/poetry/ui/command/style.rb +78 -0
  93. data/app/components/poetry/ui/composable_trigger.rb +63 -0
  94. data/app/components/poetry/ui/context_menu/component.html.erb +6 -0
  95. data/app/components/poetry/ui/context_menu/component.rb +358 -0
  96. data/app/components/poetry/ui/context_menu/preview.rb +111 -0
  97. data/app/components/poetry/ui/context_menu/style.rb +59 -0
  98. data/app/components/poetry/ui/data_table/component.html.erb +78 -0
  99. data/app/components/poetry/ui/data_table/component.rb +277 -0
  100. data/app/components/poetry/ui/data_table/preview.rb +68 -0
  101. data/app/components/poetry/ui/data_table/state.rb +115 -0
  102. data/app/components/poetry/ui/data_table/style.rb +42 -0
  103. data/app/components/poetry/ui/date_field/component.html.erb +7 -0
  104. data/app/components/poetry/ui/date_field/component.rb +209 -0
  105. data/app/components/poetry/ui/date_field/preview.rb +42 -0
  106. data/app/components/poetry/ui/date_field/style.rb +35 -0
  107. data/app/components/poetry/ui/date_picker/component.html.erb +23 -0
  108. data/app/components/poetry/ui/date_picker/component.rb +242 -0
  109. data/app/components/poetry/ui/date_picker/preview.rb +31 -0
  110. data/app/components/poetry/ui/date_picker/style.rb +25 -0
  111. data/app/components/poetry/ui/deferred/component.html.erb +16 -0
  112. data/app/components/poetry/ui/deferred/component.rb +90 -0
  113. data/app/components/poetry/ui/deferred/preview.rb +24 -0
  114. data/app/components/poetry/ui/dialog/component.html.erb +21 -0
  115. data/app/components/poetry/ui/dialog/component.rb +194 -0
  116. data/app/components/poetry/ui/dialog/preview.rb +33 -0
  117. data/app/components/poetry/ui/dialog/style.rb +30 -0
  118. data/app/components/poetry/ui/drawer/component.html.erb +16 -0
  119. data/app/components/poetry/ui/drawer/component.rb +207 -0
  120. data/app/components/poetry/ui/drawer/preview.rb +69 -0
  121. data/app/components/poetry/ui/drawer/style.rb +105 -0
  122. data/app/components/poetry/ui/dropdown_menu/component.html.erb +6 -0
  123. data/app/components/poetry/ui/dropdown_menu/component.rb +350 -0
  124. data/app/components/poetry/ui/dropdown_menu/preview.rb +101 -0
  125. data/app/components/poetry/ui/dropdown_menu/style.rb +60 -0
  126. data/app/components/poetry/ui/empty/component.html.erb +10 -0
  127. data/app/components/poetry/ui/empty/component.rb +79 -0
  128. data/app/components/poetry/ui/empty/preview.rb +27 -0
  129. data/app/components/poetry/ui/empty/style.rb +27 -0
  130. data/app/components/poetry/ui/family_identity.rb +52 -0
  131. data/app/components/poetry/ui/field/component.html.erb +19 -0
  132. data/app/components/poetry/ui/field/component.rb +183 -0
  133. data/app/components/poetry/ui/field/preview.html.erb +30 -0
  134. data/app/components/poetry/ui/field/preview.rb +72 -0
  135. data/app/components/poetry/ui/field/style.rb +68 -0
  136. data/app/components/poetry/ui/field_group/component.rb +61 -0
  137. data/app/components/poetry/ui/field_group/preview.html.erb +30 -0
  138. data/app/components/poetry/ui/field_group/preview.rb +20 -0
  139. data/app/components/poetry/ui/field_group/style.rb +25 -0
  140. data/app/components/poetry/ui/field_separator/component.rb +70 -0
  141. data/app/components/poetry/ui/field_separator/preview.rb +18 -0
  142. data/app/components/poetry/ui/field_separator/style.rb +20 -0
  143. data/app/components/poetry/ui/fieldset/component.rb +90 -0
  144. data/app/components/poetry/ui/fieldset/preview.html.erb +22 -0
  145. data/app/components/poetry/ui/fieldset/preview.rb +25 -0
  146. data/app/components/poetry/ui/fieldset/style.rb +18 -0
  147. data/app/components/poetry/ui/file_input/component.html.erb +14 -0
  148. data/app/components/poetry/ui/file_input/component.rb +188 -0
  149. data/app/components/poetry/ui/file_input/preview.rb +30 -0
  150. data/app/components/poetry/ui/file_input/style.rb +35 -0
  151. data/app/components/poetry/ui/hover_card/component.html.erb +6 -0
  152. data/app/components/poetry/ui/hover_card/component.rb +201 -0
  153. data/app/components/poetry/ui/hover_card/preview.rb +60 -0
  154. data/app/components/poetry/ui/hover_card/style.rb +17 -0
  155. data/app/components/poetry/ui/icon/component.rb +127 -0
  156. data/app/components/poetry/ui/icon/preview.rb +21 -0
  157. data/app/components/poetry/ui/input/component.rb +80 -0
  158. data/app/components/poetry/ui/input/preview.rb +35 -0
  159. data/app/components/poetry/ui/input/style.rb +18 -0
  160. data/app/components/poetry/ui/input_group/button_preview.html.erb +12 -0
  161. data/app/components/poetry/ui/input_group/component.rb +69 -0
  162. data/app/components/poetry/ui/input_group/preview.rb +23 -0
  163. data/app/components/poetry/ui/input_group/search_preview.html.erb +13 -0
  164. data/app/components/poetry/ui/input_group/style.rb +40 -0
  165. data/app/components/poetry/ui/input_group/textarea_preview.html.erb +10 -0
  166. data/app/components/poetry/ui/input_group_field.rb +61 -0
  167. data/app/components/poetry/ui/input_otp/component.html.erb +25 -0
  168. data/app/components/poetry/ui/input_otp/component.rb +250 -0
  169. data/app/components/poetry/ui/input_otp/preview.rb +91 -0
  170. data/app/components/poetry/ui/input_otp/style.rb +36 -0
  171. data/app/components/poetry/ui/item/component.html.erb +13 -0
  172. data/app/components/poetry/ui/item/component.rb +99 -0
  173. data/app/components/poetry/ui/item/group_preview.html.erb +15 -0
  174. data/app/components/poetry/ui/item/preview.rb +52 -0
  175. data/app/components/poetry/ui/item/style.rb +45 -0
  176. data/app/components/poetry/ui/kbd/component.rb +47 -0
  177. data/app/components/poetry/ui/kbd/group_preview.html.erb +4 -0
  178. data/app/components/poetry/ui/kbd/preview.rb +23 -0
  179. data/app/components/poetry/ui/kbd/style.rb +13 -0
  180. data/app/components/poetry/ui/label/component.rb +46 -0
  181. data/app/components/poetry/ui/label/preview.html.erb +5 -0
  182. data/app/components/poetry/ui/label/preview.rb +16 -0
  183. data/app/components/poetry/ui/label/style.rb +13 -0
  184. data/app/components/poetry/ui/link/component.rb +62 -0
  185. data/app/components/poetry/ui/link/preview.rb +31 -0
  186. data/app/components/poetry/ui/link/style.rb +20 -0
  187. data/app/components/poetry/ui/marker/component.html.erb +4 -0
  188. data/app/components/poetry/ui/marker/component.rb +81 -0
  189. data/app/components/poetry/ui/marker/preview.rb +43 -0
  190. data/app/components/poetry/ui/marker/style.rb +24 -0
  191. data/app/components/poetry/ui/menubar/component.html.erb +3 -0
  192. data/app/components/poetry/ui/menubar/component.rb +493 -0
  193. data/app/components/poetry/ui/menubar/preview.rb +164 -0
  194. data/app/components/poetry/ui/menubar/style.rb +71 -0
  195. data/app/components/poetry/ui/menus.rb +386 -0
  196. data/app/components/poetry/ui/message/component.html.erb +8 -0
  197. data/app/components/poetry/ui/message/component.rb +64 -0
  198. data/app/components/poetry/ui/message/preview.rb +33 -0
  199. data/app/components/poetry/ui/message/style.rb +29 -0
  200. data/app/components/poetry/ui/message_scroller/component.html.erb +15 -0
  201. data/app/components/poetry/ui/message_scroller/component.rb +163 -0
  202. data/app/components/poetry/ui/message_scroller/preview.rb +33 -0
  203. data/app/components/poetry/ui/message_scroller/style.rb +39 -0
  204. data/app/components/poetry/ui/metadata_list/component.rb +88 -0
  205. data/app/components/poetry/ui/metadata_list/preview.rb +55 -0
  206. data/app/components/poetry/ui/metadata_list/style.rb +35 -0
  207. data/app/components/poetry/ui/meter/component.rb +117 -0
  208. data/app/components/poetry/ui/meter/preview.rb +28 -0
  209. data/app/components/poetry/ui/meter/style.rb +16 -0
  210. data/app/components/poetry/ui/native_select/component.rb +124 -0
  211. data/app/components/poetry/ui/native_select/labelled_preview.html.erb +7 -0
  212. data/app/components/poetry/ui/native_select/preview.rb +20 -0
  213. data/app/components/poetry/ui/native_select/style.rb +22 -0
  214. data/app/components/poetry/ui/navigation_menu/bar_preview.html.erb +14 -0
  215. data/app/components/poetry/ui/navigation_menu/component.html.erb +25 -0
  216. data/app/components/poetry/ui/navigation_menu/component.rb +254 -0
  217. data/app/components/poetry/ui/navigation_menu/preview.rb +20 -0
  218. data/app/components/poetry/ui/navigation_menu/style.rb +73 -0
  219. data/app/components/poetry/ui/navigation_menu/viewport_preview.html.erb +23 -0
  220. data/app/components/poetry/ui/number_field/component.html.erb +19 -0
  221. data/app/components/poetry/ui/number_field/component.rb +263 -0
  222. data/app/components/poetry/ui/number_field/preview.rb +66 -0
  223. data/app/components/poetry/ui/number_field/style.rb +14 -0
  224. data/app/components/poetry/ui/pagination/component.html.erb +40 -0
  225. data/app/components/poetry/ui/pagination/component.rb +182 -0
  226. data/app/components/poetry/ui/pagination/preview.rb +47 -0
  227. data/app/components/poetry/ui/pagination/style.rb +27 -0
  228. data/app/components/poetry/ui/popover/component.html.erb +17 -0
  229. data/app/components/poetry/ui/popover/component.rb +203 -0
  230. data/app/components/poetry/ui/popover/preview.html.erb +29 -0
  231. data/app/components/poetry/ui/popover/preview.rb +66 -0
  232. data/app/components/poetry/ui/popover/style.rb +26 -0
  233. data/app/components/poetry/ui/popper_consumer.rb +69 -0
  234. data/app/components/poetry/ui/progress/component.rb +95 -0
  235. data/app/components/poetry/ui/progress/preview.rb +22 -0
  236. data/app/components/poetry/ui/progress/style.rb +19 -0
  237. data/app/components/poetry/ui/questionnaire/component.html.erb +80 -0
  238. data/app/components/poetry/ui/questionnaire/component.rb +396 -0
  239. data/app/components/poetry/ui/questionnaire/preview.rb +99 -0
  240. data/app/components/poetry/ui/questionnaire/style.rb +53 -0
  241. data/app/components/poetry/ui/radio_group/component.html.erb +4 -0
  242. data/app/components/poetry/ui/radio_group/component.rb +323 -0
  243. data/app/components/poetry/ui/radio_group/preview.rb +109 -0
  244. data/app/components/poetry/ui/radio_group/style.rb +53 -0
  245. data/app/components/poetry/ui/resizable/component.html.erb +10 -0
  246. data/app/components/poetry/ui/resizable/component.rb +178 -0
  247. data/app/components/poetry/ui/resizable/preview.rb +42 -0
  248. data/app/components/poetry/ui/resizable/style.rb +30 -0
  249. data/app/components/poetry/ui/scroll_area/component.rb +76 -0
  250. data/app/components/poetry/ui/scroll_area/preview.rb +35 -0
  251. data/app/components/poetry/ui/scroll_area/style.rb +19 -0
  252. data/app/components/poetry/ui/search_field/component.html.erb +15 -0
  253. data/app/components/poetry/ui/search_field/component.rb +137 -0
  254. data/app/components/poetry/ui/search_field/preview.rb +23 -0
  255. data/app/components/poetry/ui/search_field/style.rb +18 -0
  256. data/app/components/poetry/ui/select/component.html.erb +15 -0
  257. data/app/components/poetry/ui/select/component.rb +639 -0
  258. data/app/components/poetry/ui/select/preview.rb +113 -0
  259. data/app/components/poetry/ui/select/style.rb +58 -0
  260. data/app/components/poetry/ui/sensitive_input/component.html.erb +29 -0
  261. data/app/components/poetry/ui/sensitive_input/component.rb +274 -0
  262. data/app/components/poetry/ui/sensitive_input/preview.rb +40 -0
  263. data/app/components/poetry/ui/sensitive_input/style.rb +49 -0
  264. data/app/components/poetry/ui/separator/component.rb +57 -0
  265. data/app/components/poetry/ui/separator/preview.rb +19 -0
  266. data/app/components/poetry/ui/separator/style.rb +15 -0
  267. data/app/components/poetry/ui/sheet/component.html.erb +21 -0
  268. data/app/components/poetry/ui/sheet/component.rb +97 -0
  269. data/app/components/poetry/ui/sheet/preview.rb +57 -0
  270. data/app/components/poetry/ui/sheet/style.rb +47 -0
  271. data/app/components/poetry/ui/sidebar/component.html.erb +20 -0
  272. data/app/components/poetry/ui/sidebar/component.rb +249 -0
  273. data/app/components/poetry/ui/sidebar/preview.rb +21 -0
  274. data/app/components/poetry/ui/sidebar/shell_preview.html.erb +47 -0
  275. data/app/components/poetry/ui/sidebar/style.rb +139 -0
  276. data/app/components/poetry/ui/skeleton/component.rb +39 -0
  277. data/app/components/poetry/ui/skeleton/preview.rb +22 -0
  278. data/app/components/poetry/ui/skeleton/style.rb +13 -0
  279. data/app/components/poetry/ui/slider/component.html.erb +13 -0
  280. data/app/components/poetry/ui/slider/component.rb +360 -0
  281. data/app/components/poetry/ui/slider/preview.rb +73 -0
  282. data/app/components/poetry/ui/slider/style.rb +55 -0
  283. data/app/components/poetry/ui/spinner/component.rb +58 -0
  284. data/app/components/poetry/ui/spinner/preview.rb +22 -0
  285. data/app/components/poetry/ui/spinner/style.rb +12 -0
  286. data/app/components/poetry/ui/stat/component.html.erb +13 -0
  287. data/app/components/poetry/ui/stat/component.rb +102 -0
  288. data/app/components/poetry/ui/stat/preview.rb +47 -0
  289. data/app/components/poetry/ui/stat/style.rb +29 -0
  290. data/app/components/poetry/ui/switch/component.html.erb +15 -0
  291. data/app/components/poetry/ui/switch/component.rb +136 -0
  292. data/app/components/poetry/ui/switch/preview.rb +66 -0
  293. data/app/components/poetry/ui/switch/style.rb +22 -0
  294. data/app/components/poetry/ui/table/component.html.erb +3 -0
  295. data/app/components/poetry/ui/table/component.rb +93 -0
  296. data/app/components/poetry/ui/table/preview.rb +96 -0
  297. data/app/components/poetry/ui/table/style.rb +26 -0
  298. data/app/components/poetry/ui/tabs/component.html.erb +10 -0
  299. data/app/components/poetry/ui/tabs/component.rb +253 -0
  300. data/app/components/poetry/ui/tabs/preview.rb +43 -0
  301. data/app/components/poetry/ui/tabs/style.rb +49 -0
  302. data/app/components/poetry/ui/tag_group/component.html.erb +8 -0
  303. data/app/components/poetry/ui/tag_group/component.rb +203 -0
  304. data/app/components/poetry/ui/tag_group/preview.rb +43 -0
  305. data/app/components/poetry/ui/tag_group/style.rb +29 -0
  306. data/app/components/poetry/ui/textarea/component.rb +64 -0
  307. data/app/components/poetry/ui/textarea/preview.rb +75 -0
  308. data/app/components/poetry/ui/textarea/style.rb +18 -0
  309. data/app/components/poetry/ui/time_field/component.html.erb +6 -0
  310. data/app/components/poetry/ui/time_field/component.rb +85 -0
  311. data/app/components/poetry/ui/time_field/preview.rb +40 -0
  312. data/app/components/poetry/ui/time_field/style.rb +30 -0
  313. data/app/components/poetry/ui/timeline/component.rb +134 -0
  314. data/app/components/poetry/ui/timeline/preview.rb +61 -0
  315. data/app/components/poetry/ui/timeline/style.rb +54 -0
  316. data/app/components/poetry/ui/toast/component.html.erb +24 -0
  317. data/app/components/poetry/ui/toast/component.rb +178 -0
  318. data/app/components/poetry/ui/toast/preview.rb +69 -0
  319. data/app/components/poetry/ui/toast/style.rb +33 -0
  320. data/app/components/poetry/ui/toast_trigger/addressed_preview.html.erb +12 -0
  321. data/app/components/poetry/ui/toast_trigger/component.html.erb +3 -0
  322. data/app/components/poetry/ui/toast_trigger/component.rb +82 -0
  323. data/app/components/poetry/ui/toast_trigger/default_preview.html.erb +11 -0
  324. data/app/components/poetry/ui/toast_trigger/preview.rb +19 -0
  325. data/app/components/poetry/ui/toaster/component.html.erb +1 -0
  326. data/app/components/poetry/ui/toaster/component.rb +99 -0
  327. data/app/components/poetry/ui/toaster/positions_preview.html.erb +24 -0
  328. data/app/components/poetry/ui/toaster/preview.rb +46 -0
  329. data/app/components/poetry/ui/toaster/stamp_preview.html.erb +15 -0
  330. data/app/components/poetry/ui/toaster/style.rb +26 -0
  331. data/app/components/poetry/ui/toggle/component.rb +120 -0
  332. data/app/components/poetry/ui/toggle/preview.rb +79 -0
  333. data/app/components/poetry/ui/toggle/style.rb +33 -0
  334. data/app/components/poetry/ui/toggle_group/component.html.erb +4 -0
  335. data/app/components/poetry/ui/toggle_group/component.rb +252 -0
  336. data/app/components/poetry/ui/toggle_group/preview.rb +143 -0
  337. data/app/components/poetry/ui/toggle_group/style.rb +33 -0
  338. data/app/components/poetry/ui/toolbar/component.rb +125 -0
  339. data/app/components/poetry/ui/toolbar/preview.rb +41 -0
  340. data/app/components/poetry/ui/toolbar/style.rb +20 -0
  341. data/app/components/poetry/ui/tooltip/component.html.erb +20 -0
  342. data/app/components/poetry/ui/tooltip/component.rb +185 -0
  343. data/app/components/poetry/ui/tooltip/preview.rb +74 -0
  344. data/app/components/poetry/ui/tooltip/previews/provider_row.html.erb +25 -0
  345. data/app/components/poetry/ui/tooltip/style.rb +24 -0
  346. data/app/components/poetry/ui/tree/component.html.erb +32 -0
  347. data/app/components/poetry/ui/tree/component.rb +233 -0
  348. data/app/components/poetry/ui/tree/preview.rb +55 -0
  349. data/app/components/poetry/ui/tree/style.rb +29 -0
  350. data/app/components/poetry/ui/typeset/component.rb +73 -0
  351. data/app/components/poetry/ui/typeset/preview.rb +45 -0
  352. data/app/components/poetry/ui/typeset/style.rb +15 -0
  353. data/app/controllers/poetry/ui/llms_controller.rb +28 -0
  354. data/app/helpers/poetry/ui/components_helper.rb +1794 -0
  355. data/app/helpers/poetry/ui/form_builder/type_inference.rb +117 -0
  356. data/app/helpers/poetry/ui/form_builder.rb +916 -0
  357. data/app/helpers/poetry/ui/optimistic_form_builder.rb +55 -0
  358. data/app/helpers/poetry/ui/toast_stream_actions.rb +27 -0
  359. data/config/component_descriptions.yml +96 -0
  360. data/config/component_registry.yml +12257 -0
  361. data/config/locales/en.yml +93 -0
  362. data/config/routes.rb +8 -0
  363. data/config/template_classes.txt +113 -0
  364. data/lib/generators/poetry/add/add_generator.rb +218 -0
  365. data/lib/generators/poetry/agent_rules/agent_rules_generator.rb +91 -0
  366. data/lib/generators/poetry/agents/agents_generator.rb +24 -0
  367. data/lib/generators/poetry/agents_section.rb +148 -0
  368. data/lib/generators/poetry/block/block_generator.rb +67 -0
  369. data/lib/generators/poetry/block/templates/action_bar.html.erb +45 -0
  370. data/lib/generators/poetry/block/templates/app_shell.html.erb +139 -0
  371. data/lib/generators/poetry/block/templates/data_index.html.erb +85 -0
  372. data/lib/generators/poetry/block/templates/destructive_panel.html.erb +26 -0
  373. data/lib/generators/poetry/block/templates/page_header.html.erb +25 -0
  374. data/lib/generators/poetry/block/templates/section_card.html.erb +42 -0
  375. data/lib/generators/poetry/block/templates/stepper.html.erb +79 -0
  376. data/lib/generators/poetry/block/templates/top_nav.html.erb +44 -0
  377. data/lib/generators/poetry/diff/diff_generator.rb +135 -0
  378. data/lib/generators/poetry/editor/editor_generator.rb +344 -0
  379. data/lib/generators/poetry/editor/templates/poetry-check-hook +81 -0
  380. data/lib/generators/poetry/install/install_generator.rb +364 -0
  381. data/lib/generators/poetry/pagination/pagination_generator.rb +76 -0
  382. data/lib/generators/poetry/pagination/templates/kaminari_paginator.html.erb +29 -0
  383. data/lib/generators/poetry/pagination/templates/poetry_link_renderer.rb +34 -0
  384. data/lib/generators/poetry/pagination/templates/poetry_pagy_helper.rb +28 -0
  385. data/lib/generators/poetry/recipes/agent_embed/app/javascript/controllers/agent_demo_controller.js +131 -0
  386. data/lib/generators/poetry/recipes/screen_data_index/app/controllers/orders_controller.rb +15 -0
  387. data/lib/generators/poetry/recipes/screen_data_index/app/views/orders/index.html.erb +7 -0
  388. data/lib/generators/poetry/recipes/screen_data_index/test/system/orders_index_test.rb +16 -0
  389. data/lib/generators/poetry/recipes/screen_settings/app/controllers/settings_controller.rb +14 -0
  390. data/lib/generators/poetry/recipes/screen_settings/app/views/settings/show.html.erb +9 -0
  391. data/lib/generators/poetry/recipes/screen_settings/test/system/settings_show_test.rb +16 -0
  392. data/lib/generators/poetry/scaffold_templates/scaffold_templates_generator.rb +60 -0
  393. data/lib/generators/poetry/scaffold_templates/templates/_form.html.erb.tt +77 -0
  394. data/lib/generators/poetry/scaffold_templates/templates/controller.rb.tt +78 -0
  395. data/lib/generators/poetry/scaffold_templates/templates/edit.html.erb.tt +14 -0
  396. data/lib/generators/poetry/scaffold_templates/templates/index.html.erb.tt +24 -0
  397. data/lib/generators/poetry/scaffold_templates/templates/new.html.erb.tt +11 -0
  398. data/lib/generators/poetry/scaffold_templates/templates/partial.html.erb.tt +13 -0
  399. data/lib/generators/poetry/scaffold_templates/templates/show.html.erb.tt +18 -0
  400. data/lib/generators/poetry/skill/skill_generator.rb +34 -0
  401. data/lib/generators/poetry/skill/templates/poetry-component/SKILL.md +58 -0
  402. data/lib/generators/poetry/skill/templates/poetry-component/references/anatomy.md +152 -0
  403. data/lib/generators/poetry/skill/templates/poetry-component/references/checklist.md +67 -0
  404. data/lib/generators/poetry/skill/templates/poetry-component/references/documentation.md +137 -0
  405. data/lib/generators/poetry/skill/templates/poetry-design/SKILL.md +56 -0
  406. data/lib/generators/poetry/skill/templates/poetry-design/references/audit.md +123 -0
  407. data/lib/generators/poetry/skill/templates/poetry-design/references/compose.md +73 -0
  408. data/lib/generators/poetry/skill/templates/poetry-design/references/figma.md +68 -0
  409. data/lib/generators/poetry/skill/templates/poetry-design/references/paper.md +56 -0
  410. data/lib/generators/poetry/skill/templates/poetry-design/references/study.md +47 -0
  411. data/lib/generators/poetry/skill/templates/poetry-design/references/theme.md +70 -0
  412. data/lib/generators/poetry/skills_section.rb +58 -0
  413. data/lib/poetry/ui/chat.rb +283 -0
  414. data/lib/poetry/ui/code_block_highlighter.rb +60 -0
  415. data/lib/poetry/ui/engine.rb +55 -0
  416. data/lib/poetry/ui/recipes.rb +157 -0
  417. data/lib/poetry/ui/testing/combobox.rb +167 -0
  418. data/lib/poetry/ui/testing/dialog.rb +73 -0
  419. data/lib/poetry/ui/testing/menu.rb +127 -0
  420. data/lib/poetry/ui/testing/registration.rb +58 -0
  421. data/lib/poetry/ui/testing/select.rb +147 -0
  422. data/lib/poetry/ui/testing/tester.rb +130 -0
  423. data/lib/poetry/ui/testing.rb +130 -0
  424. data/lib/poetry/ui/themes.rb +109 -0
  425. data/lib/poetry/ui/version.rb +8 -0
  426. data/lib/poetry/ui/webmcp.rb +91 -0
  427. data/lib/poetry/ui.rb +285 -0
  428. data/lib/poetry-ui.rb +5 -0
  429. data/lib/tasks/poetry/check.rake +73 -0
  430. data/lib/tasks/poetry/design.rake +293 -0
  431. data/lib/tasks/poetry/verify.rake +36 -0
  432. data/themes/default.css +1793 -0
  433. data/themes/luma.css +1764 -0
  434. data/themes/lyra.css +1768 -0
  435. data/themes/maia.css +1768 -0
  436. data/themes/mira.css +1760 -0
  437. data/themes/nova.css +1755 -0
  438. data/themes/rhea.css +1771 -0
  439. data/themes/sera.css +1757 -0
  440. data/themes/vega.css +1775 -0
  441. data/typeset/typeset.css +499 -0
  442. metadata +502 -0
@@ -0,0 +1,916 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Ui
5
+ # The poetry FormBuilder - the model-truth end of the error
6
+ # quartet: `form.field(:email)` renders a Field wrapping an Input with
7
+ # everything derived from the object, never hand-wired:
8
+ #
9
+ # label from human_attribute_name (Rails i18n)
10
+ # value from the object
11
+ # error from object.errors (auto-flow)
12
+ # required from the model's presence validators (-> aria-required
13
+ # only - the label-station rule; never the native attribute)
14
+ # ids/aria field_id + aria-describedby via Field#control_attributes
15
+ #
16
+ # Usage: form_with(model:, builder: Poetry::Ui::FormBuilder).
17
+ class FormBuilder < ActionView::Helpers::FormBuilder
18
+ include TypeInference
19
+
20
+ # The agent-facing rules: flow into the registry's form_builder
21
+ # section and llms.txt's Forms section.
22
+ AGENT_RULES = [
23
+ "Model-bound forms use form_with(model:, builder: Poetry::Ui::FormBuilder) - inside " \
24
+ "them, ALWAYS the builder methods, never bare components (the builder derives label/" \
25
+ "value/error/required/aria from the object).",
26
+ "f.input(:attribute) is the default call - the type is inferred (attachment -> file, " \
27
+ "AR enum -> select, column type, name heuristics); as: overrides it.",
28
+ "f.association(:company) reflects the association - belongs_to renders a Combobox on " \
29
+ "the foreign key, has_many the select-all checkbox group on singular_ids.",
30
+ "Validations become attributes: presence -> aria-required (NEVER native required), " \
31
+ "length -> maxlength/minlength, numericality -> min/max/step.",
32
+ "Hints/placeholders resolve from poetry_form.* i18n (simple_form.* keys keep working " \
33
+ "as a fallback); pass hint:/placeholder: to override.",
34
+ "f.submit renders a poetry Button with the Rails i18n label; f.fieldset(legend:)/" \
35
+ "f.group lay out sections; boolean f.input renders the horizontal Field " \
36
+ "(switch: true -> the setting row)."
37
+ ].freeze
38
+
39
+ # One-line summaries of the builder methods, projected into the
40
+ # registry's form_builder section (keys group related methods). The
41
+ # docblocks carry the fuller human reference for the same methods;
42
+ # FormBuilderDocsTest gates the pair against drifting apart.
43
+ METHOD_SUMMARIES = {
44
+ "input" => "the inferred entrypoint: type from as:/attachments/enums/column/name",
45
+ "association" => "reflection-derived: belongs_to -> Combobox(fk), has_many -> checkbox group(_ids)",
46
+ "field" => "Field-wrapped Input/Textarea (as: :textarea; orientation:/hint_position: pass through)",
47
+ "check_box / switch" => "bare toggles (Rails check_box parity; switch = role=switch)",
48
+ "radio_group" => "collection_radio_buttons-equivalent on RadioGroup",
49
+ "checkbox_group" => "collection_check_boxes-equivalent on the select-all group (ONE clearing hidden)",
50
+ "poetry_select / poetry_combobox" => "the rich pickers (Rails choice shapes; combobox multiple: chips)",
51
+ "native_select" => "the styled native <select> (zero JS)",
52
+ "slider / otp_field / number_field / date_field / time_field / file_input" =>
53
+ "dedicated Field-wrapped controls",
54
+ "search_field / sensitive_input / autocomplete / tag_group / date_picker / calendar" =>
55
+ "the poetry-only control mappings",
56
+ "submit / button" => "poetry Buttons (type submit; loading: opt-in)",
57
+ "fieldset / group" => "layout frames yielding the builder"
58
+ }.freeze
59
+
60
+ # The registry's form_builder section (consumed by LlmsText's Forms
61
+ # section, the MCP server, and skills - boot-free from the committed
62
+ # registry).
63
+ def self.registry_section
64
+ {
65
+ "rules" => AGENT_RULES,
66
+ "methods" => METHOD_SUMMARIES,
67
+ "input_types" => (INPUT_DISPATCH.keys + TYPED_FIELDS + %i[boolean enum]).map(&:to_s)
68
+ }
69
+ end
70
+
71
+ # One field entrypoint for field-shaped controls: as: :input (the
72
+ # default, with type:) or as: :textarea (rows: passes through) -
73
+ # Own-line controls slot in as as: values; group-shaped
74
+ # controls get dedicated methods (radio_group, slider, otp_field).
75
+ def field(method, as: :input, hint: nil, **input_options)
76
+ # tool_description: - the declarative WebMCP parameter override
77
+ # (the label describes the parameter otherwise).
78
+ if input_options.key?(:tool_description)
79
+ input_options.merge!(Webmcp.param_attributes(input_options.delete(:tool_description)))
80
+ end
81
+ field_component = field_for(method, hint: hint,
82
+ label: input_options.delete(:label),
83
+ orientation: input_options.delete(:orientation),
84
+ hint_position: input_options.delete(:hint_position))
85
+ control_options = {
86
+ name: field_name(method),
87
+ value: object.public_send(method).presence&.to_s,
88
+ **input_options,
89
+ **field_component.control_attributes.transform_keys(&:to_sym)
90
+ }
91
+ @template.render(field_component) do
92
+ @template.render(
93
+ if as == :textarea
94
+ Textarea::Component.new(**control_options)
95
+ else
96
+ Input::Component.new(**control_options)
97
+ end
98
+ )
99
+ end
100
+ end
101
+
102
+ # The f.check_box-equivalent (the toggle family's form story): name/id
103
+ # derived, checked: from the object's attribute truthiness, "1"/"0"
104
+ # plus the unchecked-hidden pair (ActionView::Helpers::Tags::CheckBox
105
+ # parity incl. hidden-input-first ordering - the Checkbox component
106
+ # renders the pair). A BARE control mapping: compose with a Field
107
+ # (control_attributes) for the label/hint/error quartet.
108
+ def check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
109
+ @template.render Checkbox::Component.new(**toggle_options(method, options, checked_value, unchecked_value))
110
+ end
111
+
112
+ # The same mapping wearing switch semantics (Rails has NO native
113
+ # switch builder): role=switch announces on/off; use it for
114
+ # instant-effect settings, check_box for values staged for submit.
115
+ def switch(method, options = {}, checked_value = "1", unchecked_value = "0")
116
+ @template.render Switch::Component.new(**toggle_options(method, options, checked_value, unchecked_value))
117
+ end
118
+
119
+ # The collection_radio_buttons-equivalent (the exclusive-choice
120
+ # story): a Field wrapping a RadioGroup, items from the collection
121
+ # ([[value, label], ...] pairs or bare values), value/label/error/
122
+ # required derived from the object. Serialization is byte-identical
123
+ # to collection_radio_buttons (one hidden native radio per item,
124
+ # shared name; nothing submits when none is checked).
125
+ def radio_group(method, collection, hint: nil, **options)
126
+ field_component = field_for(method, hint: hint, group: true)
127
+ @template.render(field_component) do
128
+ @template.render RadioGroup::Component.new(
129
+ name: field_name(method),
130
+ value: object.public_send(method).presence&.to_s,
131
+ required: required?(method),
132
+ invalid: field_component.invalid?,
133
+ # The group is named by the VISIBLE Field label via
134
+ # aria-labelledby (in group_control_attributes) - never a
135
+ # duplicated aria-label string that can drift from it.
136
+ **group_control_attributes(field_component),
137
+ **options.transform_keys(&:to_sym)
138
+ ) do |group|
139
+ collection.each do |item|
140
+ value, label = item.is_a?(Array) ? item : [item, item.to_s.humanize]
141
+ group.with_item(value: value, label: label)
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ # The bounded-numeric story: form.slider(:volume) single (label from
148
+ # human_attribute_name), form.slider(:price_range, range: true,
149
+ # label: [...]) reads an Array[2] and submits name[] (params:
150
+ # ["200", "800"] - Rails' own array convention). Field wraps for
151
+ # hint/error; the describedby lands on each THUMB.
152
+ def slider(method, range: false, hint: nil, **options)
153
+ field_component = field_for(method, hint: hint, group: true)
154
+ value = object.public_send(method)
155
+ slider_options = {
156
+ name: field_name(method),
157
+ label: options.delete(:label) || (range ? nil : field_component.label_text),
158
+ **group_control_attributes(field_component),
159
+ **options.transform_keys(&:to_sym)
160
+ }
161
+ # The thumbs carry their own names (label: - a range REQUIRES two
162
+ # distinct ones, which a group pointer would override); the Field
163
+ # label is visual-only here, so drop the group aria-labelledby.
164
+ slider_options.delete(:"aria-labelledby")
165
+ describedby = slider_options.delete(:"aria-describedby")
166
+ slider_options[:described_by] = describedby if describedby
167
+ if range
168
+ slider_options[:values] = Array(value).presence ||
169
+ [slider_options.fetch(:min, 0), slider_options.fetch(:max, 100)]
170
+ else
171
+ slider_options[:value] = value
172
+ end
173
+ @template.render(field_component) do
174
+ @template.render Slider::Component.new(**slider_options)
175
+ end
176
+ end
177
+
178
+ # The verification-code story: form.otp_field(:code, length: 6) - a
179
+ # Field wrapping an InputOTP, label/error/required from the object.
180
+ # The value is deliberately NEVER round-tripped (a rejected code is
181
+ # dead; re-rendering it invites resubmit-the-same-wrong-code loops)
182
+ # - pass value: explicitly to override.
183
+ def otp_field(method, length: 6, hint: nil, **options)
184
+ field_component = field_for(method, hint: hint)
185
+ @template.render(field_component) do
186
+ @template.render InputOtp::Component.new(
187
+ name: field_name(method),
188
+ length: length,
189
+ required: required?(method),
190
+ invalid: field_component.invalid?,
191
+ **field_component.control_attributes.slice("id", "aria-describedby").transform_keys(&:to_sym),
192
+ **options.transform_keys(&:to_sym)
193
+ )
194
+ end
195
+ end
196
+
197
+ # form.number_field(:quantity, min: 0, max: 100) - a Field wrapping
198
+ # a NumberField, label/error/required from the object. The
199
+ # hidden <input type=number> submits the raw value; format: only
200
+ # shapes the display.
201
+ def number_field(method, hint: nil, **options)
202
+ field_component = field_for(method, hint: hint)
203
+ describedby = field_component.control_attributes["aria-describedby"]
204
+ @template.render(field_component) do
205
+ @template.render NumberField::Component.new(
206
+ name: field_name(method),
207
+ value: object.public_send(method),
208
+ required: required?(method),
209
+ invalid: field_component.invalid?,
210
+ id: field_component.control_attributes["id"],
211
+ **(describedby ? { described_by: describedby } : {}),
212
+ **options.transform_keys(&:to_sym)
213
+ )
214
+ end
215
+ end
216
+
217
+ # form.date_field(:due_on) - a Field wrapping a DateField;
218
+ # params arrive as ISO yyyy-mm-dd with or without JS. OVERRIDES
219
+ # ActionView's date_field (the number_field precedent).
220
+ def date_field(method, hint: nil, **options)
221
+ field_component = field_for(method, hint: hint)
222
+ describedby = field_component.control_attributes["aria-describedby"]
223
+ @template.render(field_component) do
224
+ @template.render DateField::Component.new(
225
+ name: field_name(method),
226
+ value: object.public_send(method),
227
+ required: required?(method),
228
+ invalid: field_component.invalid?,
229
+ id: field_component.control_attributes["id"],
230
+ **(describedby ? { described_by: describedby } : {}),
231
+ **options.transform_keys(&:to_sym)
232
+ )
233
+ end
234
+ end
235
+
236
+ # form.time_field(:starts_at) - a Field wrapping a TimeField;
237
+ # params arrive as HH:MM (HH:MM:SS with seconds: true).
238
+ def time_field(method, hint: nil, **options)
239
+ field_component = field_for(method, hint: hint)
240
+ describedby = field_component.control_attributes["aria-describedby"]
241
+ @template.render(field_component) do
242
+ @template.render TimeField::Component.new(
243
+ name: field_name(method),
244
+ value: object.public_send(method),
245
+ required: required?(method),
246
+ invalid: field_component.invalid?,
247
+ id: field_component.control_attributes["id"],
248
+ **(describedby ? { described_by: describedby } : {}),
249
+ **options.transform_keys(&:to_sym)
250
+ )
251
+ end
252
+ end
253
+
254
+ # form.file_input(:document) / form.file_input(:photos, variant: :dropzone,
255
+ # multiple: true) - a Field wrapping a FileInput; the native
256
+ # input is the form value, so ActiveStorage attaches as usual.
257
+ def file_input(method, hint: nil, **options)
258
+ field_component = field_for(method, hint: hint)
259
+ describedby = field_component.control_attributes["aria-describedby"]
260
+ @template.render(field_component) do
261
+ @template.render FileInput::Component.new(
262
+ name: field_name(method, multiple: options[:multiple] || false),
263
+ invalid: field_component.invalid?,
264
+ id: field_component.control_attributes["id"],
265
+ **(describedby ? { described_by: describedby } : {}),
266
+ **options.transform_keys(&:to_sym)
267
+ )
268
+ end
269
+ end
270
+
271
+ # The FormBuilder#select-equivalent (the listbox capstone): a
272
+ # Field wrapping a Select, everything derived from the object. The
273
+ # hidden native <select> is the serialization truth (name/value/
274
+ # required ride it), the Field's control_attributes land on the
275
+ # TRIGGER (id + aria-describedby error-before-hint + aria-invalid),
276
+ # and label[for: id] click-focuses the combobox.
277
+ #
278
+ # choices accept the Rails shapes: [["Label", value], ...] pairs,
279
+ # flat %w[a b], grouped {"Group" => [["Label", value], ...]}, or nil
280
+ # plus a block of select.with_item calls. include_blank: "Choose..."
281
+ # doubles as the placeholder text (Rails include_blank semantics -
282
+ # the blank option posts "" and fails native required validation,
283
+ # which is the correct behavior).
284
+ def poetry_select(method, choices = nil, include_blank: nil, hint: nil, **options, &block)
285
+ if options.key?(:multiple)
286
+ raise ArgumentError, "poetry_select does not support multiple: - multi-select is Combobox territory"
287
+ end
288
+
289
+ field_component = field_for(method, hint: hint)
290
+ placeholder = include_blank.is_a?(String) ? include_blank : options.delete(:placeholder)
291
+ select_options = {
292
+ name: field_name(method),
293
+ value: object.public_send(method).presence&.to_s,
294
+ placeholder: placeholder,
295
+ required: required?(method),
296
+ **options.transform_keys(&:to_sym),
297
+ **field_component.control_attributes.transform_keys(&:to_sym)
298
+ }
299
+ @template.render(field_component) do
300
+ @template.render(Select::Component.new(**select_options)) do |select|
301
+ populate_select_choices(select, choices) if choices
302
+ block&.call(select)
303
+ end
304
+ end
305
+ end
306
+
307
+ # The poetry_select twin for the type-to-filter picker (the
308
+ # combobox capstone): a Field wrapping a Combobox, everything
309
+ # derived from the object. The hidden native <select> is the
310
+ # serialization truth, the Field's control_attributes land on the
311
+ # TRIGGER (id + aria-describedby error-before-hint + aria-invalid),
312
+ # and label[for: id] click-focuses the combobox.
313
+ #
314
+ # choices accept the same Rails shapes as poetry_select:
315
+ # [["Label", value], ...] pairs, flat %w[a b], grouped
316
+ # {"Group" => [["Label", value], ...]} (group parts wear Command's
317
+ # heading:), or nil plus a block of combobox.with_item calls.
318
+ # include_blank: "Choose..." doubles as the placeholder text (the
319
+ # blank option posts "" and fails native required validation -
320
+ # deselection is a form affordance, never a re-click toggle).
321
+ #
322
+ # multiple: true is the chips mode: the value is the model's ARRAY
323
+ # (params post name[] - Rails' own convention, the [] derived for
324
+ # you) and the control_attributes land on the INLINE INPUT (the
325
+ # chips field has no trigger); include_blank has no meaning there.
326
+ def poetry_combobox(method, choices = nil, include_blank: nil, hint: nil, **options, &block)
327
+ multiple = options[:multiple]
328
+ field_component = field_for(method, hint: hint)
329
+ placeholder = include_blank.is_a?(String) ? include_blank : options.delete(:placeholder)
330
+ combobox_options = {
331
+ name: field_name(method, multiple: multiple),
332
+ value: multiple ? Array(object.public_send(method)).map(&:to_s) : object.public_send(method).presence&.to_s,
333
+ placeholder: placeholder,
334
+ required: required?(method),
335
+ **options.transform_keys(&:to_sym),
336
+ **field_component.control_attributes.transform_keys(&:to_sym)
337
+ }
338
+ @template.render(field_component) do
339
+ @template.render(Combobox::Component.new(**combobox_options)) do |combobox|
340
+ populate_combobox_choices(combobox, choices) if choices
341
+ block&.call(combobox)
342
+ end
343
+ end
344
+ end
345
+
346
+ # -- f.input: the inferred entrypoint ------------------------------
347
+ # form.input(:email) - one call, everything derived: type from `as:`
348
+ # -> attachment duck-typing -> AR enum -> attribute type -> name
349
+ # heuristics; hint/placeholder from the poetry_form (or simple_form)
350
+ # i18n chain when not passed; maxlength/min/max from validations.
351
+ INPUT_DISPATCH = {
352
+ string: :field, search: :search_field, password: :password_field,
353
+ text: :text_area, number: :number_field, date: :date_field,
354
+ time: :time_field, file: :file_input, sensitive: :sensitive_input,
355
+ select: :poetry_select, combobox: :poetry_combobox,
356
+ radio_group: :radio_group, autocomplete: :autocomplete,
357
+ tag_group: :tag_group, date_picker: :date_picker, calendar: :calendar
358
+ }.freeze
359
+ # The as: values that answer with #field carrying a native input type.
360
+ TYPED_FIELDS = %i[email url tel].freeze
361
+ # The as: values whose dispatch method takes the collection as its
362
+ # second positional argument.
363
+ COLLECTION_ARGS = %i[select combobox radio_group autocomplete].freeze
364
+
365
+ # The inferred entrypoint: one call, everything derived from the
366
+ # model. The control type comes from as: when given, otherwise from
367
+ # attachment duck-typing, AR enums, the attribute/column type, and
368
+ # name heuristics; hint/placeholder resolve from the poetry_form
369
+ # (or simple_form) i18n chain when not passed; length and numeric
370
+ # validations become maxlength/min/max/step attributes.
371
+ #
372
+ # @example
373
+ # <%= form_with(model: user, builder: Poetry::Ui::FormBuilder) do |f| %>
374
+ # <%= f.input :email %>
375
+ # <%= f.input :role, as: :select, collection: %w[admin member] %>
376
+ # <%= f.input :active, switch: true %>
377
+ # <%= f.submit %>
378
+ # <% end %>
379
+ #
380
+ # @param method [Symbol] the model attribute
381
+ # @param as [Symbol, nil] explicit control type - an INPUT_DISPATCH
382
+ # key, :email/:url/:tel, :boolean, or :enum (overrides inference)
383
+ # @param collection [Enumerable, nil] choices for collection-shaped
384
+ # types (:select, :combobox, :radio_group, :autocomplete)
385
+ # @param hint [String, nil] hint text (overrides the i18n chain)
386
+ # @return [ActiveSupport::SafeBuffer] the rendered Field
387
+ def input(method, as: nil, collection: nil, hint: nil, **options)
388
+ type = as || infer_input_type(method, collection: collection)
389
+ hint ||= form_i18n(:hints, method)
390
+ options[:placeholder] = form_i18n(:placeholders, method) if options[:placeholder].nil?
391
+ options.compact!
392
+
393
+ return boolean_input(method, hint: hint, **options) if type == :boolean
394
+ return enum_input(method, hint: hint, **options) if type == :enum
395
+ if TYPED_FIELDS.include?(type)
396
+ return field(method, hint: hint, type: type, **length_attributes(method),
397
+ **options)
398
+ end
399
+
400
+ if type == :datetime
401
+ raise ArgumentError, "f.input cannot infer :datetime (no composite control yet) - " \
402
+ "pass as: :date or as: :time explicitly"
403
+ end
404
+
405
+ options = length_attributes(method).merge(options) if %i[string password text].include?(type)
406
+ options = numeric_attributes(method).merge(options) if type == :number
407
+ dispatch = INPUT_DISPATCH.fetch(type) do
408
+ raise ArgumentError, "f.input does not know as: #{type.inspect} " \
409
+ "(one of #{INPUT_DISPATCH.keys.inspect}, :email, :url, :tel, :boolean, :enum)"
410
+ end
411
+ args = COLLECTION_ARGS.include?(type) ? [method, collection] : [method]
412
+ send(dispatch, *args, hint: hint, **options)
413
+ end
414
+
415
+ # form.association(:company) - reflection-derived: belongs_to ->
416
+ # company_id + Combobox, has_many/HABTM -> singular_ids + the
417
+ # checkbox group; collection from the association klass, label
418
+ # method auto-detected (to_label/name/title/to_s, the conventional
419
+ # chain). as: overrides (:select, :combobox, :checkbox_group).
420
+ def association(method, as: nil, collection: nil, hint: nil, **)
421
+ reflection = object.class.respond_to?(:reflect_on_association) &&
422
+ object.class.reflect_on_association(method)
423
+ raise ArgumentError, "no association #{method.inspect} on #{object.class}" unless reflection
424
+ if reflection.macro == :has_one
425
+ raise ArgumentError, "has_one associations have no form control (build the record and use fields_for)"
426
+ end
427
+
428
+ collection ||= reflection.klass.all
429
+ pairs = collection.map { |item| [association_label(item), item.id] }
430
+
431
+ if reflection.macro == :belongs_to
432
+ attribute = association_attribute(reflection, method)
433
+ # radio_group items read [value, label] - the inverse of the
434
+ # select/combobox [label, value] pairs.
435
+ if as == :radio_group
436
+ radio_group(attribute, pairs.map { |label, value| [value, label] }, hint: hint, **)
437
+ else
438
+ send(INPUT_DISPATCH.fetch(as || :combobox), attribute, pairs, hint: hint, **)
439
+ end
440
+ else
441
+ collection_association(method, pairs, as, hint: hint, **)
442
+ end
443
+ end
444
+
445
+ # -- Rails-parity typed inputs -------------------------------------
446
+ # The stock helper names answer with poetry Fields: text_field ->
447
+ # field(type: :text) etc. password_field NEVER round-trips the value
448
+ # (Rails' own behavior); revealable SECRETS (API keys) are
449
+ # sensitive_input, login passwords stay type=password here.
450
+ #
451
+ # @!method text_field(method, hint: nil, **options)
452
+ # form.text_field(:name) - a Field-wrapped Input (type: :text).
453
+ # @return [ActiveSupport::SafeBuffer]
454
+ # @!method email_field(method, hint: nil, **options)
455
+ # form.email_field(:email) - a Field-wrapped Input (type: :email).
456
+ # @return [ActiveSupport::SafeBuffer]
457
+ # @!method url_field(method, hint: nil, **options)
458
+ # form.url_field(:website) - a Field-wrapped Input (type: :url).
459
+ # @return [ActiveSupport::SafeBuffer]
460
+ # @!method telephone_field(method, hint: nil, **options)
461
+ # form.telephone_field(:phone) - a Field-wrapped Input (type: :tel).
462
+ # @return [ActiveSupport::SafeBuffer]
463
+ { text_field: :text, email_field: :email, url_field: :url,
464
+ telephone_field: :tel }.each do |helper, type|
465
+ define_method(helper) do |method, hint: nil, **options|
466
+ field(method, hint: hint, type: type, **options)
467
+ end
468
+ end
469
+ # The Rails alias for telephone_field.
470
+ alias phone_field telephone_field
471
+
472
+ # form.password_field(:password) - a Field-wrapped Input
473
+ # (type: :password); the value NEVER round-trips (Rails' own
474
+ # behavior). Revealable secrets are #sensitive_input.
475
+ def password_field(method, hint: nil, **)
476
+ field(method, hint: hint, type: :password, value: nil, **)
477
+ end
478
+
479
+ # form.text_area(:bio) - a Field-wrapped Textarea (rows: passes
480
+ # through; auto-grow is CSS, never a JS autosizer).
481
+ def text_area(method, hint: nil, **)
482
+ field(method, as: :textarea, hint: hint, **)
483
+ end
484
+
485
+ # Rails' range_field IS poetry's Slider (a naked native range would
486
+ # silently bypass the Field quartet).
487
+ def range_field(method, hint: nil, **)
488
+ slider(method, hint: hint, **)
489
+ end
490
+
491
+ # form.search_field(:query) - a Field wrapping the SearchField
492
+ # (native type=search + the clear affordance).
493
+ def search_field(method, hint: nil, **options)
494
+ field_component = field_for(method, hint: hint)
495
+ describedby = field_component.control_attributes["aria-describedby"]
496
+ @template.render(field_component) do
497
+ @template.render SearchField::Component.new(
498
+ name: field_name(method),
499
+ value: object.public_send(method).presence&.to_s,
500
+ invalid: field_component.invalid?,
501
+ id: field_component.control_attributes["id"],
502
+ **(describedby ? { described_by: describedby } : {}),
503
+ **options.transform_keys(&:to_sym)
504
+ )
505
+ end
506
+ end
507
+
508
+ # form.sensitive_input(:api_key) - the revealable-secret story
509
+ # : masked at rest, reveal + optional copy:.
510
+ def sensitive_input(method, hint: nil, **options)
511
+ field_component = field_for(method, hint: hint)
512
+ describedby = field_component.control_attributes["aria-describedby"]
513
+ @template.render(field_component) do
514
+ @template.render SensitiveInput::Component.new(
515
+ name: field_name(method),
516
+ value: object.public_send(method).presence&.to_s,
517
+ invalid: field_component.invalid?,
518
+ id: field_component.control_attributes["id"],
519
+ **(describedby ? { described_by: describedby } : {}),
520
+ **options.transform_keys(&:to_sym)
521
+ )
522
+ end
523
+ end
524
+
525
+ # form.autocomplete(:city, %w[...]) - the input IS the value
526
+ # (suggestions are conveniences, not constraints); items from the
527
+ # suggestions array ([label, value] pairs or bare strings) or a
528
+ # block of auto.with_item calls.
529
+ def autocomplete(method, suggestions = nil, hint: nil, **options, &block)
530
+ field_component = field_for(method, hint: hint)
531
+ control = {
532
+ name: field_name(method),
533
+ value: object.public_send(method).presence&.to_s,
534
+ **options.transform_keys(&:to_sym),
535
+ **field_component.control_attributes.transform_keys(&:to_sym)
536
+ }
537
+ @template.render(field_component) do
538
+ @template.render(Autocomplete::Component.new(**control)) do |auto|
539
+ Array(suggestions).each do |choice|
540
+ label, value = choice.is_a?(Array) ? choice : [choice.to_s, nil]
541
+ auto.with_item(label: label, **(value ? { value: value } : {}))
542
+ end
543
+ block&.call(auto)
544
+ end
545
+ end
546
+ end
547
+
548
+ # form.native_select(:country, [["USA", "us"], ...]) - the styled
549
+ # NATIVE <select> (zero JS); poetry_select/poetry_combobox stay the
550
+ # rich paths. include_blank: posts "" (Rails semantics).
551
+ def native_select(method, choices = nil, include_blank: nil, hint: nil, **options)
552
+ field_component = field_for(method, hint: hint)
553
+ pairs = Array(choices).map { |c| c.is_a?(Array) ? c : [c.to_s, c] }
554
+ pairs.unshift([include_blank.is_a?(String) ? include_blank : "", ""]) if include_blank
555
+ describedby = field_component.control_attributes["aria-describedby"]
556
+ @template.render(field_component) do
557
+ @template.render NativeSelect::Component.new(
558
+ name: field_name(method),
559
+ options: pairs,
560
+ selected: object.public_send(method).presence&.to_s,
561
+ invalid: field_component.invalid?,
562
+ id: field_component.control_attributes["id"],
563
+ **(describedby ? { described_by: describedby } : {}),
564
+ **options.transform_keys(&:to_sym)
565
+ )
566
+ end
567
+ end
568
+
569
+ # form.tag_group(:topics) - the removable-chips story for an ARRAY
570
+ # attribute: one hidden name[] per tag, plus the leading empty
571
+ # hidden (Rails array convention: removing every tag still clears).
572
+ def tag_group(method, hint: nil, **options)
573
+ # The Field renders NO label here: TagGroup's own caption span is the
574
+ # grid's accessible name - a Field label too would render the text
575
+ # twice under one duplicated DOM id.
576
+ caption = options.delete(:label) || object.class.human_attribute_name(method)
577
+ field_component = field_for(method, hint: hint, group: true, label: false)
578
+ describedby = field_component.control_attributes["aria-describedby"]
579
+ values = Array(object.public_send(method)).map(&:to_s)
580
+ @template.hidden_field_tag(field_name(method, multiple: true), "", id: nil) +
581
+ @template.render(field_component) do
582
+ @template.render(TagGroup::Component.new(
583
+ name: field_name(method),
584
+ label: caption,
585
+ id: field_component.control_attributes["id"],
586
+ **(describedby ? { described_by: describedby } : {}),
587
+ **options.transform_keys(&:to_sym)
588
+ )) do |group|
589
+ values.each { |value| group.with_tag(value: value, label: value) }
590
+ end
591
+ end
592
+ end
593
+
594
+ # The collection_check_boxes-equivalent: a Field(group) wrapping the
595
+ # APG select-all group (DD sweep) - items post name[] and the
596
+ # leading empty hidden clears when none are checked. select_all:
597
+ # true (or a label string) adds the mixed-state parent checkbox.
598
+ def checkbox_group(method, collection, hint: nil, select_all: false, **options)
599
+ field_component = field_for(method, hint: hint, group: true)
600
+ chosen = Array(object.public_send(method)).map(&:to_s)
601
+ base_id = field_id(method)
602
+ @template.hidden_field_tag(field_name(method, multiple: true), "", id: nil) +
603
+ @template.render(field_component) do
604
+ @template.poetry_checkbox_group(
605
+ class: "flex flex-col gap-2",
606
+ **group_control_attributes(field_component).slice(:id, :"aria-labelledby", :"aria-describedby"),
607
+ **options.transform_keys(&:to_sym)
608
+ ) do
609
+ rows = []
610
+ rows << checkbox_group_all_row(base_id, select_all) if select_all
611
+ rows.concat(checkbox_group_item_rows(method, collection, chosen, base_id))
612
+ @template.safe_join(rows)
613
+ end
614
+ end
615
+ end
616
+
617
+ # form.date_picker(:due_on) - the calendar-popup pick; value posts
618
+ # ISO like date_field. (Quartet ids land on the composed control's
619
+ # root for now - the input-level aria refinement comes later.)
620
+ def date_picker(method, hint: nil, **options)
621
+ field_component = field_for(method, hint: hint, group: true)
622
+ @template.render(field_component) do
623
+ @template.render DatePicker::Component.new(
624
+ name: field_name(method),
625
+ value: object.public_send(method).presence&.to_s,
626
+ **group_control_attributes(field_component).slice(:id, :"aria-describedby"),
627
+ **options.transform_keys(&:to_sym)
628
+ )
629
+ end
630
+ end
631
+
632
+ # form.calendar(:starts_on) - the always-visible month grid as a
633
+ # form participant (mode: :range posts name[]).
634
+ def calendar(method, hint: nil, **options)
635
+ field_component = field_for(method, hint: hint, group: true)
636
+ value = object.public_send(method)
637
+ @template.render(field_component) do
638
+ @template.render Calendar::Component.new(
639
+ name: field_name(method),
640
+ # Calendar's keyword is selected:, not value: - value: would fall
641
+ # into html_attributes and the model's date silently never lands.
642
+ selected: value.is_a?(Array) ? value.map(&:to_s) : value.presence&.to_s,
643
+ **group_control_attributes(field_component).slice(:id, :"aria-describedby"),
644
+ **options.transform_keys(&:to_sym)
645
+ )
646
+ end
647
+ end
648
+
649
+ # -- Rails-arity adapters ------------------------------------------
650
+
651
+ # ActionView's select arity adapted onto poetry_select.
652
+ def select(method, choices = nil, options = {}, html_options = {}, &)
653
+ poetry_select(method, choices,
654
+ include_blank: options[:include_blank] || options[:prompt],
655
+ **html_options.transform_keys(&:to_sym), &)
656
+ end
657
+
658
+ # ActionView's collection_select arity, verbatim, adapted onto
659
+ # poetry_select (value_method/text_method read off each item).
660
+ def collection_select(method, collection, value_method, text_method, # rubocop:disable Metrics/ParameterLists
661
+ options = {}, html_options = {})
662
+ pairs = collection.map { |item| [item.public_send(text_method), item.public_send(value_method)] }
663
+ poetry_select(method, pairs,
664
+ include_blank: options[:include_blank] || options[:prompt],
665
+ **html_options.transform_keys(&:to_sym))
666
+ end
667
+
668
+ # ActionView's collection_radio_buttons arity adapted onto
669
+ # radio_group (one hidden native radio per item, same serialization).
670
+ def collection_radio_buttons(method, collection, value_method, text_method, **)
671
+ pairs = collection.map { |item| [item.public_send(value_method), item.public_send(text_method)] }
672
+ radio_group(method, pairs, **)
673
+ end
674
+
675
+ # ActionView's collection_check_boxes arity adapted onto
676
+ # checkbox_group (name[] items plus the leading clearing hidden).
677
+ def collection_check_boxes(method, collection, value_method, text_method, **)
678
+ pairs = collection.map { |item| [item.public_send(value_method), item.public_send(text_method)] }
679
+ checkbox_group(method, pairs, **)
680
+ end
681
+
682
+ # -- Actions -------------------------------------------------------
683
+
684
+ # form.submit -> a poetry Button (type submit); label from Rails'
685
+ # own i18n default ("Create Model" / "Update Model"). loading: true
686
+ # opts into the Button loading treatment.
687
+ def submit(value = nil, **options)
688
+ value ||= submit_default_value
689
+ @template.render(Button::Component.new(type: :submit, **options.transform_keys(&:to_sym))) { value }
690
+ end
691
+
692
+ # form.button - #submit with block/content support: the block (or
693
+ # value) is the Button's content, type stays submit.
694
+ def button(value = nil, **options, &block)
695
+ content = block ? @template.capture(&block) : value || submit_default_value
696
+ @template.render(Button::Component.new(type: :submit, **options.transform_keys(&:to_sym))) { content }
697
+ end
698
+
699
+ # -- Layout --------------------------------------------------------
700
+
701
+ # form.fieldset(legend: "Shipping") { |f| ... } - the grouped-fields
702
+ # frame; yields the builder for nesting.
703
+ def fieldset(legend:, hint: nil, **options, &)
704
+ @template.render(Fieldset::Component.new(legend: legend, hint: hint,
705
+ **options.transform_keys(&:to_sym))) do
706
+ @template.capture(self, &)
707
+ end
708
+ end
709
+
710
+ # form.group { |f| ... } - the FieldGroup stack (the @container the
711
+ # responsive Field orientation measures against).
712
+ def group(**options, &)
713
+ @template.render(FieldGroup::Component.new(**options.transform_keys(&:to_sym))) do
714
+ @template.capture(self, &)
715
+ end
716
+ end
717
+
718
+ private
719
+
720
+ # Rails choice shapes -> Select parts: a Hash groups (label part per
721
+ # key), arrays are ["Label", value] pairs (options_for_select-exact)
722
+ # or bare values (value doubles as the label).
723
+ def populate_select_choices(owner, choices)
724
+ if choices.is_a?(Hash)
725
+ choices.each do |group_label, group_choices|
726
+ owner.with_group(label: group_label.to_s) do |group|
727
+ group_choices.each { |choice| add_select_item(group, choice) }
728
+ end
729
+ end
730
+ else
731
+ choices.each { |choice| add_select_item(owner, choice) }
732
+ end
733
+ end
734
+
735
+ def add_select_item(owner, choice)
736
+ label, value = choice.is_a?(Array) ? choice : [choice.to_s, choice]
737
+ owner.with_item(value: value.to_s) { label.to_s }
738
+ end
739
+
740
+ # The same Rails shapes onto Combobox parts - groups wear the
741
+ # embedded Command's heading: (vs Select's label:).
742
+ def populate_combobox_choices(owner, choices)
743
+ if choices.is_a?(Hash)
744
+ choices.each do |group_heading, group_choices|
745
+ owner.with_group(heading: group_heading.to_s) do |group|
746
+ group_choices.each { |choice| add_select_item(group, choice) }
747
+ end
748
+ end
749
+ else
750
+ choices.each { |choice| add_select_item(owner, choice) }
751
+ end
752
+ end
753
+
754
+ # Group-shaped controls take the id (item ids derive from it) and
755
+ # the describedby wiring from the Field; invalid/required ride the
756
+ # component's own options (aria-invalid belongs on the ITEMS, not
757
+ # the root).
758
+ def group_control_attributes(field_component)
759
+ field_component.control_attributes
760
+ .slice("id", "aria-describedby", "aria-labelledby")
761
+ .transform_keys(&:to_sym)
762
+ end
763
+
764
+ # -- one keyword per Field surface
765
+ # label: false suppresses the Field label entirely - for controls that
766
+ # render their own accessible name (TagGroup's caption span).
767
+ def field_for(method, hint: nil, group: false, orientation: nil, hint_position: nil, label: nil)
768
+ extras = { orientation: orientation, hint_position: hint_position }.compact
769
+ Field::Component.new(
770
+ id: field_id(method),
771
+ label_text: label == false ? nil : (label || object.class.human_attribute_name(method)),
772
+ hint: hint,
773
+ error: error_for(method),
774
+ required: required?(method),
775
+ group: group,
776
+ **extras
777
+ )
778
+ end
779
+
780
+ # Shared derivation for the toggle-family builder methods: everything
781
+ # from the object, never hand-wired. required maps to aria-required
782
+ # only (the label lock - the components never render native required).
783
+ def toggle_options(method, options, checked_value, unchecked_value)
784
+ {
785
+ name: field_name(method),
786
+ id: field_id(method),
787
+ checked: ActiveModel::Type::Boolean.new.cast(object.public_send(method)) || false,
788
+ value: checked_value,
789
+ unchecked_value: unchecked_value,
790
+ required: required?(method),
791
+ **options.transform_keys(&:to_sym)
792
+ }
793
+ end
794
+
795
+ # f.input's boolean story: the horizontal boolean-control layout
796
+ # (checkbox on the label line); switch: true renders the setting row
797
+ # (label + hint left, switch right).
798
+ def boolean_input(method, hint: nil, switch: false, **options)
799
+ orientation = switch ? :setting : :horizontal
800
+ field_component = field_for(method, hint: hint, orientation: orientation)
801
+ control = toggle_options(method, options, "1", "0")
802
+ .merge(field_component.control_attributes.transform_keys(&:to_sym))
803
+ @template.render(field_component) do
804
+ @template.render((switch ? Switch::Component : Checkbox::Component).new(**control))
805
+ end
806
+ end
807
+
808
+ # AR enums: humanized keys as a select (as: :radio_group lays a
809
+ # small set flat).
810
+ def enum_input(method, hint: nil, **)
811
+ pairs = object.class.defined_enums.fetch(method.to_s).keys.map { |key| [key.humanize, key] }
812
+ poetry_select(method, pairs, hint: hint, **)
813
+ end
814
+
815
+ def association_label(item)
816
+ %i[to_label name title].each do |candidate|
817
+ return item.public_send(candidate) if item.respond_to?(candidate)
818
+ end
819
+ item.to_s
820
+ end
821
+
822
+ def association_attribute(reflection, method)
823
+ attribute = (reflection.respond_to?(:foreign_key) && reflection.foreign_key) || "#{method}_id"
824
+ attribute.to_sym
825
+ end
826
+
827
+ def collection_association(method, pairs, as, hint: nil, **)
828
+ attribute = :"#{method.to_s.singularize}_ids"
829
+ case as
830
+ when :combobox
831
+ poetry_combobox(attribute, pairs, hint: hint, multiple: true, **)
832
+ when :select, :radio_group
833
+ raise ArgumentError, "collection associations need a multi-value control (:checkbox_group or :combobox)"
834
+ else
835
+ checkbox_group(attribute, pairs.map { |label, value| [value, label] }, hint: hint, **)
836
+ end
837
+ end
838
+
839
+ # The checkbox-group rows (extracted for the coverage the builder
840
+ # method reads better without).
841
+ def checkbox_group_all_row(base_id, select_all)
842
+ all_id = "#{base_id}_all"
843
+ @template.content_tag(:div, class: "flex items-center gap-2") do
844
+ @template.poetry_checkbox_group_all(id: all_id) +
845
+ @template.poetry_label(for_id: all_id) do
846
+ select_all.is_a?(String) ? select_all : "All"
847
+ end
848
+ end
849
+ end
850
+
851
+ def checkbox_group_item_rows(method, collection, chosen, base_id)
852
+ collection.map do |item|
853
+ value, label = item.is_a?(Array) ? item : [item, item.to_s.humanize]
854
+ item_id = "#{base_id}_#{value.to_s.parameterize(separator: "_")}"
855
+ @template.content_tag(:div, class: "flex items-center gap-2") do
856
+ @template.poetry_checkbox_group_item(
857
+ name: field_name(method, multiple: true), value: value.to_s,
858
+ checked: chosen.include?(value.to_s), unchecked_value: nil, id: item_id
859
+ ) + @template.poetry_label(for_id: item_id) { label.to_s }
860
+ end
861
+ end
862
+ end
863
+
864
+ # The dual-key error read: company_id also reads errors on :company
865
+ # (validates :company, presence: true is the Rails idiom, but the
866
+ # form field is the _id attribute).
867
+ def error_for(method)
868
+ return nil unless object.respond_to?(:errors)
869
+
870
+ message = object.errors.full_messages_for(method).first
871
+ if message.nil? && (base = method.to_s[/\A(.+?)_ids?\z/, 1])
872
+ message = object.errors.full_messages_for(base.to_sym).first ||
873
+ object.errors.full_messages_for(base.pluralize.to_sym).first
874
+ end
875
+ message
876
+ end
877
+
878
+ # Presence -> required, with the necessary filtering: conditional
879
+ # validators (:if/:unless) never claim required, and :on contexts
880
+ # must match the record's persistence.
881
+ def required?(method)
882
+ return false unless object.class.respond_to?(:validators_on)
883
+
884
+ context = object.respond_to?(:persisted?) && object.persisted? ? :update : :create
885
+ object.class.validators_on(method).any? do |validator|
886
+ next false unless validator.kind == :presence
887
+ next false if validator.options[:if] || validator.options[:unless]
888
+
889
+ on = Array(validator.options[:on])
890
+ on.empty? || on.include?(context)
891
+ end
892
+ end
893
+
894
+ # The Rails 8 respellings: ActionView 8
895
+ # aliases textarea/checkbox at ITS class body, so a subclass override
896
+ # of the old name never reaches them - define both, version-gated.
897
+ if ActionView::VERSION::MAJOR >= 8
898
+
899
+ public
900
+
901
+ # The Rails 8 spelling of #text_area.
902
+ def textarea(method, hint: nil, **) = text_area(method, hint: hint, **)
903
+
904
+ # The Rails 8 spelling of #check_box.
905
+ def checkbox(method, options = {}, checked_value = "1", unchecked_value = "0")
906
+ check_box(method, options, checked_value, unchecked_value)
907
+ end
908
+
909
+ # The Rails 8 spelling of #collection_check_boxes.
910
+ def collection_checkboxes(method, collection, value_method, text_method, **)
911
+ collection_check_boxes(method, collection, value_method, text_method, **)
912
+ end
913
+ end
914
+ end
915
+ end
916
+ end