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,1086 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Ui
5
+ # Combobox family: the searchable select - a trigger, a filterable
6
+ # listbox popup, and a native-select form story.
7
+ module Combobox
8
+ # The placement vocabularies, declared ONCE at module level - shared
9
+ # by the validations and the part-state declarations.
10
+ SIDES = %i[top right bottom left].freeze
11
+ # The closed vocabulary for the align axis.
12
+ ALIGNS = %i[start center end].freeze
13
+ # The closed vocabulary for the reading-direction axis.
14
+ DIRS = %i[ltr rtl].freeze
15
+
16
+ # The server-side option registry: collects every option in render
17
+ # order with unique non-blank values, keeps labels for the native
18
+ # <select> and the value display, assigns the server-stable option
19
+ # ids ("#{id}-item-<n>") that aria-activedescendant depends on, and
20
+ # seats the initial highlight (the selected option, else the first
21
+ # enabled item). Duplicate or blank values raise at render.
22
+ #
23
+ # @api private
24
+ class OptionSet
25
+ # One registered option: value, label, disabled.
26
+ # @api private
27
+ Entry = Struct.new(:value, :label, :disabled)
28
+
29
+ attr_reader :entries, :highlighted_id
30
+
31
+ def initialize(base_id:, highlight_value: nil)
32
+ @base_id = base_id
33
+ @highlight_value = highlight_value.presence
34
+ @entries = []
35
+ @seen = Set.new
36
+ @highlighted_id = nil
37
+ end
38
+
39
+ # Registers one option; returns [item_id, highlighted].
40
+ def register(value:, label:, disabled: false)
41
+ key = value.to_s
42
+ raise ArgumentError, "Combobox item requires a non-blank value:" if key.blank?
43
+ unless @seen.add?(key)
44
+ raise ArgumentError, "duplicate Combobox option value #{key.inspect} - option values must be " \
45
+ "unique within their combobox"
46
+ end
47
+
48
+ item_id = "#{@base_id}-item-#{@entries.size}"
49
+ @entries << Entry.new(key, label, disabled)
50
+ highlighted = highlight?(key, disabled)
51
+ @highlighted_id = item_id if highlighted
52
+ [item_id, highlighted]
53
+ end
54
+
55
+ def label_for(value)
56
+ entries.find { |entry| entry.value == value }&.label
57
+ end
58
+
59
+ private
60
+
61
+ # A selected value seats the highlight on ITS option (unless
62
+ # disabled; the controller's open-seed falls back client-side);
63
+ # no value -> first enabled (Command's rule).
64
+ def highlight?(value, disabled)
65
+ return false if @highlighted_id || disabled
66
+
67
+ @highlight_value ? value == @highlight_value : true
68
+ end
69
+ end
70
+
71
+ # Shared part builders for the option union - mixed into the root
72
+ # Component and the nested Group so both levels render the same
73
+ # anatomy through the same Builder. Hosts must expose #option_set
74
+ # (the shared OptionSet) and #selected_value.
75
+ #
76
+ # @api private
77
+ module Helpers
78
+ private
79
+
80
+ def item_component(**)
81
+ Item.new(option_set: option_set, selected_value: selected_value, **)
82
+ end
83
+
84
+ # The engine's separator part (Command::Style verbatim) - hidden by
85
+ # the controller whenever the query is non-empty.
86
+ def separator_part(**options)
87
+ attrs = {
88
+ # aria-hidden, never role=separator: inside role=listbox a
89
+ # separator role is flagged (the select/command axe rule).
90
+ "data-slot" => "combobox-separator", "aria-hidden" => "true",
91
+ "class" => Command::Style.css(:separator, class: options.delete(:class))
92
+ }
93
+ content_tag(:div, nil, attrs.merge(options))
94
+ end
95
+
96
+ # COMBOBOX-OWNED addition onto each combobox-item: the
97
+ # committed-value check - TRAILING (ms-auto), not Select's
98
+ # absolute gutter. Server-rendered always; the item's
99
+ # data-selected absence hides it in CSS while unselected.
100
+ def item_indicator
101
+ content_tag(:span, "data-slot" => "combobox-item-indicator",
102
+ "class" => Style.css(:item_indicator, class: Style.css(:item_indicator_state))) do
103
+ render(Icon::Component.new(name: :check, class: Style.css(:indicator_check)))
104
+ end
105
+ end
106
+ end
107
+
108
+ # One role=option div carrying BOTH meanings: it stays a valid
109
+ # COMMAND item
110
+ # (data-value + data-poetry-collection-item + the engine's
111
+ # activate/pointerHighlight actions + keywords/filter_value/
112
+ # always_render, NO tabindex - activedescendant, never DOM focus)
113
+ # AND wears Select's committed-value surface (aria-selected +
114
+ # data-selected twin-written together, never separately, plus the
115
+ # trailing indicator). A part component ON PURPOSE: rendering
116
+ # happens in DOM order, so the shared OptionSet assigns server-
117
+ # stable ids and registers native <option>s in exactly the order
118
+ # the listbox renders - top level or grouped.
119
+ #
120
+ # @api private
121
+ class Item < Poetry::Core::Component
122
+ internal_component!
123
+ include Helpers
124
+
125
+ attr_reader :option_set, :selected_value
126
+
127
+ def initialize(option_set:, selected_value:, value:, **options)
128
+ super(options)
129
+ @option_set = option_set
130
+ @selected_value = selected_value
131
+ @value = value.to_s
132
+ @disabled = options.delete(:disabled) || false
133
+ @text_value = options.delete(:text_value)
134
+ @keywords = Array(options.delete(:keywords)).map(&:to_s)
135
+ @filter_value = options.delete(:filter_value)
136
+ @always_render = options.delete(:always_render) || false
137
+ end
138
+
139
+ def call
140
+ label_html = content || "".html_safe
141
+ plain_label = (@text_value.presence || ActionView::Base.full_sanitizer.sanitize(label_html.to_s)).squish
142
+ item_id, highlighted = option_set.register(value: @value, label: plain_label, disabled: @disabled)
143
+
144
+ # multiple passes the committed LIST - selection is ARRAY
145
+ # INCLUSION; single stays the scalar equality.
146
+ selected = if selected_value.is_a?(Array)
147
+ selected_value.include?(@value)
148
+ else
149
+ selected_value.present? && @value == selected_value
150
+ end
151
+ attrs = {
152
+ "id" => item_id, "data-slot" => "combobox-item", "role" => "option",
153
+ "data-poetry-collection-item" => "", "data-value" => @value,
154
+ "aria-selected" => selected.to_s,
155
+ "class" => Command::Style.css(:item, class: [Style.css(:item_fill),
156
+ html_attributes.delete(:class)].compact.join(" "))
157
+ }.merge(stimulus_attributes(:command) do |command|
158
+ command.with_action(:activate, on: :click)
159
+ command.with_action(:pointer_highlight, on: :pointermove)
160
+ end)
161
+ # The selected state: bare data-selected on the committed
162
+ # option, NOTHING while unselected (absence IS the state).
163
+ attrs["data-selected"] = "" if selected
164
+ attrs["data-highlighted"] = "" if highlighted
165
+ if @disabled
166
+ attrs["aria-disabled"] = "true"
167
+ attrs["data-disabled"] = ""
168
+ end
169
+ attrs["data-text-value"] = @text_value if @text_value
170
+ attrs["data-keywords"] = @keywords.join(" ") if @keywords.any?
171
+ attrs["data-filter-value"] = @filter_value if @filter_value
172
+ attrs["data-always-render"] = "" if @always_render
173
+ content_tag(:div, Poetry::Core::HTML::Attributes.merged(attrs, html_attributes)) do
174
+ safe_join([content_tag(:span, label_html, "data-slot" => "combobox-item-text",
175
+ "class" => Style.css(:item_text)),
176
+ item_indicator])
177
+ end
178
+ end
179
+ end
180
+
181
+ # role=group labelled by its heading part (Command's group shape,
182
+ # the source's combobox geometry: unpadded, the label themed) -
183
+ # the same item union one level down, registering its options into
184
+ # the PARENT's option set so ids, the native <select>, and the
185
+ # value display see every option in DOM order. Kept internal ON
186
+ # PURPOSE: nested parts are anatomy, not registered components.
187
+ #
188
+ # @api private
189
+ class Group < Poetry::Core::Component
190
+ internal_component!
191
+ include Helpers
192
+
193
+ attr_reader :option_set, :selected_value
194
+
195
+ renders_many :items,
196
+ doc: "The group's members: with_item options and with_separator dividers, interleaved in " \
197
+ "declaration order.",
198
+ types: {
199
+ item: { renders: ->(**options) { item_component(**options) }, as: :item },
200
+ separator: { renders: ->(**options) { separator_part(**options) }, as: :separator }
201
+ }
202
+
203
+ def initialize(option_set:, selected_value:, heading:, always_render: false, **extra_attributes)
204
+ raise ArgumentError, "Combobox group requires heading: (the group's accessible name)" if heading.blank?
205
+
206
+ super(extra_attributes)
207
+ @option_set = option_set
208
+ @selected_value = selected_value
209
+ @heading_text = heading
210
+ @always_render = always_render
211
+ end
212
+
213
+ def before_render
214
+ raise ArgumentError, "Combobox group requires at least one item" unless items?
215
+ end
216
+
217
+ def call
218
+ attrs = { "data-slot" => "combobox-group", "role" => "group", "aria-labelledby" => heading_id }
219
+ # The group is unpadded and hookless (the list carries the
220
+ # inset) - a class attribute only when the caller passes one.
221
+ classes = Style.css(:group, class: html_attributes.delete(:class))
222
+ attrs["class"] = classes if classes.present?
223
+ attrs["data-always-render"] = "" if @always_render
224
+ content_tag(:div, Poetry::Core::HTML::Attributes.merged(attrs, html_attributes)) do
225
+ safe_join([heading_part, *items])
226
+ end
227
+ end
228
+
229
+ private
230
+
231
+ def group_id
232
+ @group_id ||= poetry_instance_id("poetry-combobox-group")
233
+ end
234
+
235
+ def heading_id
236
+ "#{group_id}-heading"
237
+ end
238
+
239
+ def heading_part
240
+ content_tag(:div, @heading_text, "data-slot" => "combobox-label", "id" => heading_id,
241
+ "class" => Style.css(:label))
242
+ end
243
+ end
244
+
245
+ # A searchable select: a button that opens a popup holding a filter
246
+ # input and a listbox of options, committing the chosen value to a
247
+ # visually hidden native <select> that is the form's source of
248
+ # truth. Options are server-rendered and filtered client-side as
249
+ # you type; the selected option's label shows in the trigger.
250
+ #
251
+ # Opening focuses the filter input (the selected option is
252
+ # highlighted and scrolled into view, not focused); Tab while open
253
+ # closes WITHOUT committing; a printable key on the closed trigger
254
+ # opens the popup and seeds the filter.
255
+ #
256
+ # With multiple: true, value: takes a LIST, a chips field (chips in
257
+ # value order plus the filter input inline) replaces the trigger,
258
+ # the native <select multiple> posts name[], the listbox turns
259
+ # aria-multiselectable, and selection TOGGLES with the popup
260
+ # staying open.
261
+ #
262
+ # @example
263
+ # render Poetry::Ui::Combobox::Component.new(name: "framework", "aria-label" => "Framework") do |combobox|
264
+ # combobox.with_item(value: "rails") { "Ruby on Rails" }
265
+ # combobox.with_item(value: "hanami") { "Hanami" }
266
+ # end
267
+ class Component < Poetry::Core::Component
268
+ include Helpers
269
+
270
+ # The trigger-bound ARIA surface: Field's control_attributes (and
271
+ # bare aria-label usage) land on the TRIGGER - the combobox is the
272
+ # interactive control the label must reach - never on the root div.
273
+ TRIGGER_ARIA_KEYS = %w[label labelledby describedby invalid required].freeze
274
+
275
+ # Projected into the registry, llms.txt, and the agent surface.
276
+ AGENT_RULES = [
277
+ "Use poetry_combobox (f.poetry_combobox in forms) - never hand-wire Popover+Command+hidden-input; " \
278
+ "this component IS that wiring, with the form story done right.",
279
+ "Combobox picks VALUES. Filter-then-ACT is bare Command; short known lists are Select; free text " \
280
+ "is Input.",
281
+ "Every Combobox MUST be named (Field label via id: or aria-label) - a nameless bare combobox " \
282
+ "fails at render.",
283
+ "NEVER write aria-selected from highlight logic (position is data-highlighted + " \
284
+ "aria-activedescendant); NEVER write the display without the native select first - the commit " \
285
+ "pipeline does all of it; agents patching DOM must too.",
286
+ "Async options: filter: false + the Turbo-frame ?q= recipe - AND the frame must render the twin " \
287
+ "native <option> for every committable item (the recipe's one hard rule).",
288
+ "multiple: true is the multi-select/chips mode: value: takes an ARRAY, the native <select " \
289
+ "multiple> posts name[] (the [] is appended for you), selection TOGGLES with the popup " \
290
+ "staying open, and chips replace the trigger - never fake multi with hidden inputs.",
291
+ "Do not put interactive elements inside options (an option IS the interactive unit).",
292
+ "Deselection in single mode is include_blank (a visible blank option) or show_clear: (the " \
293
+ "trigger-side X - single mode only), never a re-click toggle - " \
294
+ "committing the already-selected value closes without change. In multiple, re-committing " \
295
+ "IS the deselect gesture (chip-remove is its pointer twin)."
296
+ ].freeze
297
+
298
+ # The required slots, stated statically so static checks can flag
299
+ # a missing item without rendering.
300
+ REQUIRED_SLOTS = { item: "at least one item" }.freeze
301
+
302
+ renders_one :trigger, doc: "Optional custom trigger content rendered BEFORE the value span (rare); the " \
303
+ "component owns role=combobox + the aria wiring + the chevrons regardless, so " \
304
+ "composition cannot drop the contract."
305
+
306
+ renders_one :empty, doc: "Custom zero-results content (defaults to t('poetry.combobox.empty'))."
307
+ renders_one :loading, doc: "Custom pending content (a spinner); the HOST toggles visibility (Turbo frame " \
308
+ "events) - the part renders hidden (Command parity)."
309
+
310
+ renders_many :items,
311
+ doc: "The option UNION forwarded to the embedded command list: item | group (heading + items) " \
312
+ "| separator - one ordered collection (interleaving preserved; items and groups are part " \
313
+ "COMPONENTS so option registration follows render/DOM order).",
314
+ types: {
315
+ item: { renders: ->(**options) { item_component(**options) }, as: :item },
316
+ group: {
317
+ renders: lambda { |**options|
318
+ Group.new(option_set: option_set, selected_value: selected_value, **options)
319
+ },
320
+ as: :group
321
+ },
322
+ separator: { renders: ->(**options) { separator_part(**options) }, as: :separator }
323
+ }
324
+
325
+ use_stimulus do
326
+ on :root do
327
+ controller :combobox do
328
+ register
329
+ value :open
330
+ value :value, from: :value_payload
331
+ value :modal
332
+ value :multiple, if: :multiple
333
+ end
334
+ # multiple mounts the command engine on the ROOT (the inline
335
+ # input sits outside the popup); single keeps it on the
336
+ # popup's command part.
337
+ controller :command, if: :multiple do
338
+ register
339
+ value :filter
340
+ value :loop
341
+ end
342
+ controller :popper do
343
+ register
344
+ value :side
345
+ value :align
346
+ value :side_offset
347
+ value :align_offset
348
+ value :avoid_collisions
349
+ end
350
+ end
351
+ on :trigger do
352
+ controller :combobox do
353
+ action :toggle, on: :click
354
+ action :trigger_keydown, on: :keydown
355
+ end
356
+ controller(:popper) { target :anchor }
357
+ end
358
+ # multiple: the chips frame replaces the trigger as the anchor.
359
+ on :chips do
360
+ controller(:combobox) { action :chips_pointerdown, on: :mousedown }
361
+ controller(:popper) { target :anchor }
362
+ end
363
+ # multiple: both engines' keyboard maps ride the one input.
364
+ on :inline_input do
365
+ controller :command do
366
+ action :filter_input, on: :input
367
+ action :keydown, on: :keydown
368
+ end
369
+ controller(:combobox) { action :input_keydown, on: :keydown }
370
+ end
371
+ on :content do
372
+ controller(:popper) { target :content }
373
+ end
374
+ # single-mode: the embedded engine root inside the popup.
375
+ on :command_part do
376
+ controller :command do
377
+ register
378
+ value :filter
379
+ value :loop
380
+ end
381
+ end
382
+ on :input do
383
+ controller :command do
384
+ action :filter_input, on: :input
385
+ action :keydown, on: :keydown
386
+ end
387
+ end
388
+ on :item do
389
+ controller :command do
390
+ action :activate, on: :click
391
+ action :pointer_highlight, on: :pointermove
392
+ end
393
+ end
394
+ on :native do
395
+ controller(:combobox) { action :native_changed, on: :change }
396
+ end
397
+ on :clear do
398
+ controller(:combobox) { action :clear, on: :click }
399
+ end
400
+ on :chip do
401
+ controller(:combobox) { action :chip_keydown, on: :keydown }
402
+ end
403
+ on :chip_remove do
404
+ controller(:combobox) { action :remove_chip, on: :click }
405
+ end
406
+ end
407
+
408
+ # The operate surface: what an in-page agent may do to a rendered
409
+ # combobox (registered per instance, opt-in - see poetry-agent).
410
+ tool :set_value,
411
+ description: "Select the option whose value matches; the schema lists the rendered options, " \
412
+ "and clear selects nothing.",
413
+ params: { value: { type: "string", required: true, description: "The option value to select." } },
414
+ executes: %i[combobox set_value],
415
+ mutating: true
416
+ tool :clear,
417
+ description: "Clear the current selection.",
418
+ executes: %i[combobox clear],
419
+ mutating: true
420
+
421
+ # The rendered instance knows its options (the option union renders
422
+ # into a capture before the root's attributes): the payload's schema
423
+ # lists the enabled values as the enum, each with its label as the
424
+ # title - an agent reads "Japan" and passes "jp" - so an unknown
425
+ # value is refused before dispatch instead of committing nothing.
426
+ # multiple: keeps the bare schema (its value is a list).
427
+ def webmcp_tool_definition(definition)
428
+ return definition unless definition["name"] == "set_value" && !multiple
429
+
430
+ entries = option_set.entries.reject(&:disabled)
431
+ return definition if entries.empty?
432
+
433
+ schema = definition.fetch("inputSchema")
434
+ value = schema.fetch("properties").fetch("value").merge(
435
+ "enum" => entries.map(&:value),
436
+ "anyOf" => entries.map { |entry| { "type" => "string", "const" => entry.value, "title" => entry.label } }
437
+ )
438
+ definition.merge("inputSchema" => schema.merge("properties" => schema["properties"].merge("value" => value)))
439
+ end
440
+
441
+ option :value, :string, doc: "The committed value; with multiple:, an array of values."
442
+ option :name, :string, doc: "The form field name on the native <select>; multiple: appends [] for you."
443
+ option :placeholder, :string,
444
+ doc: "Shown in the value display (multiple: in the inline input) while nothing is committed."
445
+ option :search_placeholder, :string, doc: "Placeholder for the popup's filter input (single mode)."
446
+ option :id, :string, doc: "The trigger's DOM id - the Field label target; every other part id derives from it."
447
+ option :open, :boolean, default: false, doc: "Server-renders the popup open."
448
+ option :required, :boolean, default: false, doc: "Forwards to the native <select> for constraint validation."
449
+ option :disabled, :boolean, default: false,
450
+ doc: "Disables the trigger, the filter input, and the native select."
451
+ option :multiple, :boolean, default: false,
452
+ doc: "Multi-select mode: value: becomes LIST-capable (single stays the scalar), " \
453
+ "the trigger is replaced by the chips field, the native <select multiple> " \
454
+ "posts name[], selection toggles without closing."
455
+ option :modal, :boolean, default: false,
456
+ doc: "DEFAULT FALSE - popover semantics (Tab-out closes, no scrim). true restores " \
457
+ "the focus-scope trap for dialog-critical pickers."
458
+ option :show_clear, :boolean, default: false,
459
+ doc: "Single mode only: the trigger-side deselection X - swaps in over the " \
460
+ "chevrons while a value is committed and commits the blank value, so the " \
461
+ "cleared state serializes as \"\"."
462
+ option :filter, :boolean, default: true,
463
+ doc: "Forwarded to the embedded engine: false = server-driven options (the async " \
464
+ "Turbo-frame recipe)."
465
+ option :loop, :boolean, default: false, doc: "Wraps arrow-key highlight movement past either end of the list."
466
+ option :side, :symbol, default: :bottom,
467
+ doc: "The popup's preferred side of the trigger; collisions may flip it."
468
+ option :align, :symbol, default: :start, doc: "The popup's alignment along the trigger's edge."
469
+ option :side_offset, :integer, default: 4, doc: "Gap in px between the trigger and the popup."
470
+ option :align_offset, :integer, default: 0, doc: "Skid in px along the aligned edge."
471
+ option :avoid_collisions, :boolean, default: true, doc: "Flips/shifts the popup to stay inside the viewport."
472
+ option :dir, :symbol, doc: "Writing-direction override (ltr/rtl) stamped on the root."
473
+ option :width, :string,
474
+ doc: "The trigger width utility class; the popup ALWAYS tracks the trigger's measured width, so one " \
475
+ "knob sizes both surfaces. nil resolves to the dictionary's default (w-50)."
476
+
477
+ validates :side, inclusion: { in: SIDES }
478
+ validates :align, inclusion: { in: ALIGNS }
479
+ validates :dir, inclusion: { in: DIRS }, allow_nil: true
480
+
481
+ part "combobox", "Root wrapper carrying both controllers (combobox + popper) and the " \
482
+ "optional dir attribute"
483
+ part "combobox-native", "The visually-hidden native <select> - the serialization truth " \
484
+ "(Select's decision verbatim); plumbing, never styled or targeted"
485
+ part "combobox-trigger", "The role=combobox button (the demo's outline Button) the field " \
486
+ "label reaches - value display and chevrons ride inside",
487
+ states: {
488
+ "data-placeholder" => "no option is committed (bare; the controller toggles it " \
489
+ "on every commit)",
490
+ "data-popup-open" => "the popup is open (bare while open, absent while closed - " \
491
+ "the controller flips it with the open state)"
492
+ }
493
+ part "combobox-value", "The value display span - the selected option's label, or the placeholder",
494
+ states: {
495
+ "data-placeholder" => "placeholder: is given - carries the placeholder text so " \
496
+ "the controller can restore it"
497
+ }
498
+ part "combobox-chip-input", "The inline filter input beside the chips (multiple: only) - the one " \
499
+ "typing surface; the source's chip input"
500
+ part "combobox-content", "The popper-positioned popup housing the embedded Command " \
501
+ "anatomy - open/closed and the resolved placement ride here",
502
+ states: {
503
+ "data-open" => "popup is open (the controller flips the pair at runtime)",
504
+ "data-closed" => "popup is closed or animating out (the server-rendered state)",
505
+ "data-chips" => "multiple: - chips mode; the popup's minimum width follows the chips field",
506
+ "data-side" => { condition: "the placement side - server-rendered from side:, " \
507
+ "rewritten to the resolved side by popper on open",
508
+ values: SIDES.map(&:to_s) },
509
+ "data-align" => { condition: "the placement alignment - server-rendered from " \
510
+ "align:, rewritten by popper on open",
511
+ values: ALIGNS.map(&:to_s) }
512
+ },
513
+ vars: {
514
+ "--transform-origin" => "popper - the animation origin matching the resolved placement",
515
+ "--available-width" => "popper - viewport space available to the popup post-flip",
516
+ "--available-height" => "popper - viewport space available to the popup post-flip",
517
+ "--anchor-width" => "popper - the trigger's measured width (the popup width " \
518
+ "tracks it - one knob, two surfaces)",
519
+ "--anchor-height" => "popper - the trigger's measured height"
520
+ }
521
+ part "combobox-clear", "The show_clear: deselection X - a trigger sibling seated over " \
522
+ "the chevron slot; pressing it commits the blank value and " \
523
+ "returns focus to the trigger. Wears the html hidden attribute " \
524
+ "while no value is committed (the controller flips it on every " \
525
+ "commit; the chevron swap derives from that one flip in CSS)"
526
+ part "combobox-command", "The embedded engine root - Command's anatomy rendered here against its " \
527
+ "own controller (composition at the markup contract)"
528
+ part "combobox-input-wrapper", "The input row - search icon + filter input above the list"
529
+ part "combobox-search-icon", "Decorative search glyph beside the input"
530
+ part "combobox-input", "The filter input (role=combobox) - the typing session and " \
531
+ "aria-activedescendant live here. Single: in the popup with its own " \
532
+ "accessible name; multiple: INLINE in the chips frame (the " \
533
+ "input-inside layout), where the field label reaches it",
534
+ states: {
535
+ "data-popup-open" => "multiple: the popup is open (bare while open, absent while " \
536
+ "closed - the input carries the flip; single's trigger owns it)"
537
+ }
538
+ part "combobox-list", "THE role=listbox - the aria-controls target of both combobox roles"
539
+ part "combobox-empty", "Zero-matches message - rendered hidden; the engine unhides it " \
540
+ "when the filter pass leaves no visible items"
541
+ part "combobox-group", "role=group labelled by its heading - hidden by the engine when " \
542
+ "every member item is filtered out"
543
+ part "combobox-label", "The group heading - styled, no ARIA role (the group " \
544
+ "points at it via aria-labelledby)"
545
+ part "combobox-item", "One role=option div wearing BOTH meanings - a Command item " \
546
+ "(filtering + highlight) AND Select's committed-value surface",
547
+ states: {
548
+ "data-value" => "always - the option's committable value (the native <option> twin)",
549
+ "data-selected" => "the option is committed (bare; absent while unselected - the " \
550
+ "controller twin-writes it with aria-selected)",
551
+ "data-highlighted" => "the item holds the activedescendant highlight (the server " \
552
+ "seeds it; the engine moves it with the input's " \
553
+ "aria-activedescendant)",
554
+ "data-disabled" => "disabled: is set (aria-disabled rides along)",
555
+ "data-hidden" => "the filter scored the item zero (the engine pairs it with " \
556
+ "hidden; never rendered server-side)"
557
+ }
558
+ part "combobox-item-text", "The option's label span - the filter/typematch text source"
559
+ part "combobox-item-indicator", "The trailing committed-value check (ms-auto per the " \
560
+ "demo) - the parent item's data-selected absence hides it"
561
+ part "combobox-separator", "Decorative divider (aria-hidden) - hidden by the engine " \
562
+ "whenever the query is non-empty"
563
+ part "combobox-loading", "Pending affordance (role=status) - rendered hidden; the HOST " \
564
+ "unhides it around async refills"
565
+ part "combobox-status", "The engine's sr-only polite result-count live region",
566
+ states: {
567
+ "data-zero" => "always - the localized zero-results template",
568
+ "data-one" => "always - the localized one-result template",
569
+ "data-other" => "always - the localized many-results template (a literal count " \
570
+ "placeholder the controller interpolates)"
571
+ }
572
+ part "combobox-chips", "The chips FIELD frame (multiple: only) - the popper anchor " \
573
+ "replacing the trigger; chips + the inline input flex-wrap inside, " \
574
+ "and role=toolbar rides it only while it holds >=1 chip",
575
+ states: {
576
+ "data-placeholder" => "the selection is empty (bare; the controller flips it on " \
577
+ "every commit - the toolbar role departs with it)",
578
+ "data-disabled" => "disabled: is set - every chip mutation is gated",
579
+ "data-remove-label" => "always - the localized chip-remove template (a literal " \
580
+ "label placeholder the controller interpolates for " \
581
+ "client-built chips)"
582
+ }
583
+ part "combobox-chip", "One committed value (multiple: only) - a div taking REAL focus " \
584
+ "(tabindex=-1, styled by :focus-visible; chips NEVER wear " \
585
+ "data-highlighted), named by its value text, holding the remove button",
586
+ states: {
587
+ "data-value" => "always - the chip's committed value (the native <option> twin)",
588
+ "data-disabled" => "disabled: is set (chip focus is blocked entirely)"
589
+ }
590
+ part "combobox-chip-remove", "The chip's native remove button (tabindex=-1, labelled " \
591
+ "'Remove <label>') - a press removes the value and is never " \
592
+ "a chips-area press"
593
+
594
+ # Normalizes the multiple-mode list value before the typed
595
+ # attribute write.
596
+ # @api private
597
+ def initialize(attributes = {})
598
+ # multiple: value: is LIST-capable (single keeps the scalar
599
+ # :string cast) - the array is normalized ahead of the typed
600
+ # attribute write, List-cast semantics (scalars adopt as
601
+ # one-element lists).
602
+ if attributes.values_at(:multiple, "multiple").any?
603
+ raw = attributes.delete(:value) { attributes.delete("value") }
604
+ @list_value = Array(raw).map(&:to_s).select(&:present?).uniq
605
+ end
606
+
607
+ super
608
+ @trigger_aria = extract_trigger_aria!
609
+ end
610
+
611
+ # Enforces the item, naming, and mode-compatibility contracts.
612
+ # @api private
613
+ def before_render
614
+ raise ArgumentError, "Combobox requires at least one item (with_item / with_group)" unless items?
615
+
616
+ if show_clear && multiple
617
+ raise ArgumentError, "show_clear: is single-mode only - multiple already has per-chip " \
618
+ "removal and the closed-popup Escape wipe"
619
+ end
620
+
621
+ return if named?
622
+
623
+ raise ArgumentError, "Combobox requires an accessible name - compose with a Field label " \
624
+ "(id: + label[for: id]) or pass 'aria-label'"
625
+ end
626
+
627
+ # The Field-targetable id lands on the TRIGGER (label[for=id]
628
+ # click-focuses the combobox); every other part derives from it -
629
+ # server-generated, portal-safe, stream-safe. Stable ids:
630
+ # '#{id}' trigger / '#{id}-content' / '#{id}-list' (the
631
+ # aria-controls target of BOTH combobox roles) / '#{id}-native' /
632
+ # '#{id}-input' / '#{id}-item-<n>'.
633
+ # @api private
634
+ def trigger_id
635
+ @trigger_id ||= id.presence || poetry_instance_id("poetry-combobox")
636
+ end
637
+
638
+ # The popup panel's id.
639
+ # @api private
640
+ def content_id
641
+ "#{trigger_id}-content"
642
+ end
643
+
644
+ # The listbox's id - both combobox roles point aria-controls here.
645
+ # @api private
646
+ def list_id
647
+ "#{trigger_id}-list"
648
+ end
649
+
650
+ # The hidden native select's id.
651
+ # @api private
652
+ def native_id
653
+ "#{trigger_id}-native"
654
+ end
655
+
656
+ # The popup filter input's id (single mode).
657
+ # @api private
658
+ def input_id
659
+ "#{trigger_id}-input"
660
+ end
661
+
662
+ # The shared option registry for this render.
663
+ # @api private
664
+ def option_set
665
+ # multiple seeds the highlight on the FIRST committed value (the
666
+ # scalar rule, list-shaped).
667
+ @option_set ||= OptionSet.new(base_id: trigger_id,
668
+ highlight_value: multiple ? selected_values.first : selected_value)
669
+ end
670
+
671
+ # single: the committed scalar; multiple: the committed LIST (array
672
+ # inclusion is the selection test everywhere downstream - items,
673
+ # native options, chips).
674
+ # @api private
675
+ def selected_value
676
+ @selected_value ||= multiple ? selected_values : value.presence.to_s
677
+ end
678
+
679
+ # The committed list (multiple mode; empty otherwise).
680
+ # @api private
681
+ def selected_values
682
+ @list_value || []
683
+ end
684
+
685
+ # The committed option's label for the value display (single mode).
686
+ # @api private
687
+ def selected_label
688
+ option_set.label_for(selected_value) if !multiple && selected_value.present?
689
+ end
690
+
691
+ # Attributes for the root wrapper.
692
+ # @api private
693
+ def root_attributes
694
+ root = { "data-slot" => "combobox" }
695
+ root["dir"] = dir.to_s if dir
696
+ html_attributes.merge_if_not_set(
697
+ root.merge(root_stimulus_attributes).merge(component_data_attributes)
698
+ )
699
+ end
700
+
701
+ # The serialization truth: a real <select> carrying
702
+ # name/required/disabled and ALL options with selected - visually
703
+ # hidden (sr-only, painted) and out of both trees (aria-hidden +
704
+ # tabindex=-1). The change action is the autofill-adoption path.
705
+ # multiple flips the multiple attribute and posts the Rails array
706
+ # convention (name[], appended unless the given name already ends
707
+ # with it).
708
+ # @api private
709
+ def native_select
710
+ attrs = {
711
+ "id" => native_id, "data-slot" => "combobox-native",
712
+ "aria-hidden" => "true", "tabindex" => "-1", "class" => Style.css(:native)
713
+ }
714
+ attrs["name"] = native_name if name.present?
715
+ attrs["multiple"] = true if multiple
716
+ attrs["required"] = true if required
717
+ attrs["disabled"] = true if disabled
718
+ attrs.merge!(stimulus_attributes_for(:native))
719
+ content_tag(:select, native_options, attrs)
720
+ end
721
+
722
+ # The width utility the trigger/chips carry - the option, or the
723
+ # dictionary default (w-50).
724
+ # @api private
725
+ def width_classes = width || css(:default_width)
726
+
727
+ # The combobox-role trigger button: aria-controls points at the
728
+ # LISTBOX (the a11y-true relationship the controller resolves the
729
+ # popup through), aria-haspopup=listbox names the popup kind, and
730
+ # there is NO aria-autocomplete here - the typing session belongs
731
+ # to the popup input.
732
+ # @api private
733
+ def trigger_button
734
+ attrs = {
735
+ "id" => trigger_id, "data-slot" => "combobox-trigger", "type" => "button",
736
+ "role" => "combobox", "aria-expanded" => open.to_s, "aria-controls" => list_id,
737
+ "aria-haspopup" => "listbox",
738
+ "class" => classnames(css(:trigger), width_classes)
739
+ }
740
+ # The trigger state: bare data-popup-open while open, NO
741
+ # attribute while closed (absence IS the state).
742
+ attrs["data-popup-open"] = "" if open
743
+ attrs["data-placeholder"] = "" unless selected_label
744
+ attrs["disabled"] = true if disabled
745
+ attrs.merge!(trigger_stimulus_attributes)
746
+ attrs.merge!(trigger_aria_attributes)
747
+ content_tag(:button, attrs) do
748
+ safe_join([leading_content, chevrons])
749
+ end
750
+ end
751
+
752
+ # A custom trigger slot (leading icon) GROUPS with the value display
753
+ # - three bare children under justify-between would strand the text
754
+ # mid-row (icon left, text center, chevrons right). Slot-less
755
+ # triggers keep the flat two-child markup.
756
+ # @api private
757
+ def leading_content
758
+ return value_display unless trigger?
759
+
760
+ content_tag(:span, safe_join([trigger, value_display]), "class" => Style.css(:trigger_leading))
761
+ end
762
+
763
+ # The chips FIELD (multiple: - replaces the trigger with an
764
+ # input-inside layout): the popper anchor frame holding one chip
765
+ # per committed value IN VALUE ORDER, the inline filter input
766
+ # (data-slot=command-input - the engine's markup contract), and
767
+ # the <template> chip skeleton the controller clones for
768
+ # client-side commits. role=toolbar rides the frame only while it
769
+ # holds chips; an empty selection wears data-placeholder instead.
770
+ # @api private
771
+ def chips_frame
772
+ attrs = {
773
+ "data-slot" => "combobox-chips",
774
+ "data-remove-label" => t("poetry.combobox.remove", label: "%{label}"), # rubocop:disable Style/FormatStringToken
775
+ "class" => classnames(css(:chips), width_classes)
776
+ }
777
+ attrs["role"] = "toolbar" if selected_values.any?
778
+ attrs["data-placeholder"] = "" if selected_values.empty?
779
+ attrs["data-disabled"] = "" if disabled
780
+ attrs.merge!(chips_stimulus_attributes)
781
+ content_tag(:div, attrs) do
782
+ chips = selected_values.map { |chip_value| chip_part(chip_value) }
783
+ safe_join(chips + [inline_input, chip_template])
784
+ end
785
+ end
786
+
787
+ # The popup (popper CONTENT; focus-scope + dismissable tokens are
788
+ # appended by the controller on open - NEVER roving-focus, the
789
+ # embedded Command is activedescendant). No role: the listbox
790
+ # lives inside.
791
+ # @api private
792
+ def content_attributes
793
+ attrs = {
794
+ "id" => content_id, "data-slot" => "combobox-content", "tabindex" => "-1",
795
+ (open ? "data-open" : "data-closed") => "",
796
+ # Initial placement, re-resolved live by popper on open.
797
+ "data-side" => side, "data-align" => align,
798
+ "class" => css(:content)
799
+ }.merge(stimulus_attributes_for(:content))
800
+ attrs["hidden"] = true unless open
801
+ attrs
802
+ end
803
+
804
+ # The committed value(s) as the controller's value payload.
805
+ # @api private
806
+ def value_payload = multiple ? selected_values : value.to_s
807
+
808
+ # The embedded engine root with filter/loop forwarded - the
809
+ # composition boundary: this component renders Command's anatomy
810
+ # against the engine's own controller, reused unchanged. Nesting
811
+ # Command::Component instead would collide ids (its root id would
812
+ # duplicate the trigger's), could not seat the indicator inside
813
+ # each item, and could not retune the input's height.
814
+ # @api private
815
+ def command_attributes
816
+ { "data-slot" => "combobox-command", "class" => Command::Style.css }
817
+ .merge(stimulus_attributes_for(:command_part))
818
+ end
819
+
820
+ # The popup's filter input (Command's contract, retuned to h-9):
821
+ # its OWN accessible name - t('.filter_label'), distinct
822
+ # from the field label naming the trigger. aria-expanded is
823
+ # statically true (the listbox is always rendered inside the
824
+ # popup; the TRIGGER carries the dynamic flip).
825
+ # @api private
826
+ def input_attributes
827
+ attrs = {
828
+ "type" => "text", "id" => input_id, "data-slot" => "combobox-input",
829
+ "role" => "combobox", "aria-expanded" => "true", "aria-controls" => list_id,
830
+ "aria-autocomplete" => "list", "autocomplete" => "off", "autocorrect" => "off",
831
+ "spellcheck" => "false", "aria-label" => t("poetry.combobox.filter_label"),
832
+ "class" => Command::Style.css(:input, class: css(:input_fill))
833
+ }
834
+ # The source stamps chips mode on the popup (its min-width follows the field).
835
+ attrs["data-chips"] = "true" if multiple
836
+ attrs["placeholder"] = search_placeholder if search_placeholder.present?
837
+ attrs["disabled"] = true if disabled
838
+ attrs["aria-activedescendant"] = option_set.highlighted_id if option_set.highlighted_id
839
+ attrs.merge!(stimulus_attributes_for(:input))
840
+ attrs
841
+ end
842
+
843
+ # THE listbox - the aria-controls target of both combobox roles
844
+ # (the trigger resolves the popup through it). multiple declares
845
+ # aria-multiselectable. Wears the combobox's own list rule (the
846
+ # inset rides the list; the parts wear the source's combobox
847
+ # vocabulary - the engine resolves either family's slot names).
848
+ # @api private
849
+ def list_attributes
850
+ attrs = {
851
+ "id" => list_id, "data-slot" => "combobox-list", "role" => "listbox",
852
+ "tabindex" => "-1", "aria-label" => t("poetry.command.list_label"),
853
+ "class" => css(:list)
854
+ }
855
+ attrs["aria-multiselectable"] = "true" if multiple
856
+ attrs
857
+ end
858
+
859
+ # Zero-matches message - rendered hidden; the engine unhides it
860
+ # when the filter pass leaves no visible items.
861
+ # @api private
862
+ def empty_part
863
+ content_tag(:div, empty? ? empty : t("poetry.combobox.empty"),
864
+ "data-slot" => "combobox-empty", "hidden" => true, "class" => Command::Style.css(:empty))
865
+ end
866
+
867
+ # Pending affordance (role=status) - rendered hidden; the HOST
868
+ # unhides it around async refills (Command's part, shared keys).
869
+ # @api private
870
+ def loading_part
871
+ content_tag(:div, "data-slot" => "combobox-loading", "role" => "status",
872
+ "hidden" => true, "class" => Command::Style.css(:loading)) do
873
+ safe_join([content_tag(:span, t("poetry.command.loading"), class: Command::Style.css(:sr_only)),
874
+ loading].compact)
875
+ end
876
+ end
877
+
878
+ # The engine's sr-only polite result-count region (localized
879
+ # templates carried as data attributes - Command's keys, shared).
880
+ # @api private
881
+ def status_part
882
+ content_tag(:span, nil,
883
+ "data-slot" => "combobox-status", "role" => "status", "aria-live" => "polite",
884
+ "class" => Command::Style.css(:status),
885
+ "data-zero" => t("poetry.command.results", count: 0),
886
+ "data-one" => t("poetry.command.results", count: 1),
887
+ "data-other" => t("poetry.command.results.other", count: "%{count}")) # rubocop:disable Style/FormatStringToken
888
+ end
889
+
890
+ private
891
+
892
+ def named?
893
+ id.present? || @trigger_aria["label"].present? || @trigger_aria["labelledby"].present?
894
+ end
895
+
896
+ # Pull the trigger-bound aria-* out of the root's html attributes
897
+ # (both flat "aria-label" and nested aria: {label:} spellings) so
898
+ # Field's control_attributes wire the combobox, not the wrapper.
899
+ def extract_trigger_aria!
900
+ aria = {}.with_indifferent_access
901
+ nested = @html_attributes.delete("aria")
902
+ nested.each { |nested_key, nested_value| aria[nested_key] = nested_value } if nested.is_a?(Hash)
903
+ @html_attributes.keys.grep(/\Aaria-/).each do |flat|
904
+ aria[flat.delete_prefix("aria-")] = @html_attributes.delete(flat)
905
+ end
906
+ aria
907
+ end
908
+
909
+ def trigger_aria_attributes
910
+ TRIGGER_ARIA_KEYS.each_with_object({}) do |key, attrs|
911
+ attrs["aria-#{key}"] = @trigger_aria[key] unless @trigger_aria[key].nil?
912
+ end
913
+ end
914
+
915
+ # The value DISPLAY: server-rendered selected-option label (no
916
+ # FOUC), or the placeholder text; data-placeholder on the span
917
+ # carries the placeholder so the controller can restore it.
918
+ def value_display
919
+ attrs = { "data-slot" => "combobox-value", "class" => css(:value) }
920
+ attrs["data-placeholder"] = placeholder if placeholder.present?
921
+ content_tag(:span, selected_label || placeholder, attrs)
922
+ end
923
+
924
+ # The double chevron is the combobox tell (Select wears
925
+ # chevron-down). show_clear adds the swap class: the chevron
926
+ # goes invisible (keeping its box) while the sibling X is showable.
927
+ def chevrons
928
+ classes = classnames(Style.css(:trigger_icon), (Style.css(:trigger_icon_swap) if show_clear))
929
+ render(Icon::Component.new(name: :"chevrons-up-down", class: classes))
930
+ end
931
+
932
+ # The show_clear: X - a SIBLING of the trigger (button-in-button is
933
+ # invalid HTML), absolutely seated over the chevron slot. Server
934
+ # renders the truthful initial state (hidden with no value); the
935
+ # controller flips it on every commit.
936
+ def clear_button
937
+ attrs = {
938
+ "data-slot" => "combobox-clear", "type" => "button",
939
+ "aria-label" => t("poetry.combobox.clear"), "class" => css(:clear)
940
+ }
941
+ attrs["hidden"] = true unless selected_label
942
+ attrs["disabled"] = true if disabled
943
+ attrs.merge!(stimulus_attributes_for(:clear))
944
+ content_tag(:button, attrs) do
945
+ render(Icon::Component.new(name: :x, class: Style.css(:clear_icon)))
946
+ end
947
+ end
948
+
949
+ # One chip: a div taking REAL focus (tabindex=-1, styled by
950
+ # :focus-visible - NO data-highlighted here), named by its value
951
+ # text, holding the native remove button. nil value = the blank
952
+ # skeleton the <template> ships for the controller.
953
+ def chip_part(chip_value = nil)
954
+ label = chip_value && (option_set.label_for(chip_value) || chip_value)
955
+ attrs = {
956
+ "data-slot" => "combobox-chip", "tabindex" => "-1", "class" => css(:chip)
957
+ }
958
+ attrs["data-value"] = chip_value if chip_value
959
+ attrs["aria-label"] = label if label
960
+ # aria-disabled is the truth AND the axe contrast exemption - a
961
+ # div can't carry native disabled, so without it the dimmed chip
962
+ # reads as failing text.
963
+ attrs["aria-disabled"] = "true" if disabled
964
+ attrs["data-disabled"] = "" if disabled
965
+ attrs.merge!(stimulus_attributes_for(:chip))
966
+ content_tag(:div, attrs) do
967
+ safe_join([label, chip_remove_part(label)].compact)
968
+ end
969
+ end
970
+
971
+ # ChipRemove: a NATIVE button (tabindex=-1) labelled "Remove
972
+ # <label>" via I18n, so every chip's remove affordance has an
973
+ # accessible name.
974
+ def chip_remove_part(label)
975
+ attrs = {
976
+ "data-slot" => "combobox-chip-remove", "type" => "button", "tabindex" => "-1",
977
+ "class" => css(:chip_remove)
978
+ }
979
+ attrs["aria-label"] = t("poetry.combobox.remove", label: label) if label
980
+ attrs["disabled"] = true if disabled
981
+ attrs.merge!(stimulus_attributes_for(:chip_remove))
982
+ content_tag(:button, attrs) do
983
+ render(Icon::Component.new(name: :x, class: Style.css(:chip_remove_icon)))
984
+ end
985
+ end
986
+
987
+ # The blank chip skeleton the controller clones per client-side
988
+ # commit - SERVER markup stays the single source of chip anatomy.
989
+ def chip_template
990
+ content_tag(:template, chip_part)
991
+ end
992
+
993
+ def inline_input
994
+ tag.input(**Poetry::Core::HTML::Attributes.new(inline_input_attributes).to_attributes)
995
+ end
996
+
997
+ # The inline filter input (multiple): the ONE typing surface - the
998
+ # Field-targetable id lands HERE (no trigger exists), aria-expanded
999
+ # is DYNAMIC (the input sits outside the popup, unlike single's
1000
+ # always-rendered listbox), and the input NEVER mirrors selection
1001
+ # text (the placeholder rides it; chips are the value display).
1002
+ def inline_input_attributes
1003
+ attrs = {
1004
+ "type" => "text", "id" => trigger_id, "data-slot" => "combobox-chip-input",
1005
+ "role" => "combobox", "aria-expanded" => open.to_s, "aria-controls" => list_id,
1006
+ "aria-haspopup" => "listbox", "aria-autocomplete" => "list", "autocomplete" => "off",
1007
+ "autocorrect" => "off", "spellcheck" => "false", "class" => css(:chip_input)
1008
+ }
1009
+ # The input state: bare data-popup-open while open, NO
1010
+ # attribute while closed (absence IS the state).
1011
+ attrs["data-popup-open"] = "" if open
1012
+ attrs["placeholder"] = placeholder if placeholder.present?
1013
+ attrs["disabled"] = true if disabled
1014
+ attrs["aria-activedescendant"] = option_set.highlighted_id if open && option_set.highlighted_id
1015
+ attrs.merge!(inline_input_stimulus_attributes)
1016
+ attrs.merge!(trigger_aria_attributes)
1017
+ attrs
1018
+ end
1019
+
1020
+ # The frame carries the popper anchor AND the combobox press action
1021
+ # - one Attributes instance (the Accordion lesson, via Select).
1022
+ def chips_stimulus_attributes
1023
+ stimulus_attributes_for(:chips)
1024
+ end
1025
+
1026
+ # BOTH controllers' keyboard maps ride the one input: the engine's
1027
+ # filter/arrows/Enter, then the shell's chips map (Backspace /
1028
+ # ArrowLeft / reopen / the closed-popup Escape wipe).
1029
+ def inline_input_stimulus_attributes
1030
+ stimulus_attributes_for(:inline_input)
1031
+ end
1032
+
1033
+ # The blank option (Rails include_blank semantics, value="") rides
1034
+ # the placeholder - and is always present when no option matches,
1035
+ # so the native select never silently rests on the first option
1036
+ # while the trigger shows the placeholder (Select-exact). multiple
1037
+ # has no blank option (an empty multi-select just posts nothing);
1038
+ # selected flags flip to array inclusion.
1039
+ def native_options
1040
+ rendered = []
1041
+ # show_clear forces the blank option even while a value is
1042
+ # committed - the cleared state must serialize as "" (a native
1043
+ # select with no blank option cannot rest on nothing).
1044
+ if !multiple && (placeholder.present? || selected_label.nil? || show_clear)
1045
+ rendered << tag.option(placeholder.to_s, value: "", selected: selected_label.nil? || nil)
1046
+ end
1047
+ option_set.entries.each do |entry|
1048
+ rendered << tag.option(entry.label, value: entry.value,
1049
+ selected: native_selected?(entry.value) || nil,
1050
+ disabled: entry.disabled || nil)
1051
+ end
1052
+ safe_join(rendered)
1053
+ end
1054
+
1055
+ def native_name
1056
+ return name unless multiple
1057
+
1058
+ name.end_with?("[]") ? name : "#{name}[]"
1059
+ end
1060
+
1061
+ def native_selected?(entry_value)
1062
+ multiple ? selected_values.include?(entry_value) : entry_value == selected_value
1063
+ end
1064
+
1065
+ # BOTH controllers build into ONE Attributes instance - a plain
1066
+ # Hash#merge of two would overwrite data-controller instead of
1067
+ # token-concatenating it (the Accordion lesson, via Select).
1068
+ # multiple adds a THIRD: the command engine mounts on the ROOT (the
1069
+ # inline input sits outside the popup, so the engine must scope
1070
+ # over both) - single keeps it on the popup's command part.
1071
+ def root_stimulus_attributes
1072
+ stimulus_attributes_for(:root)
1073
+ end
1074
+
1075
+ def trigger_stimulus_attributes
1076
+ stimulus_attributes_for(:trigger)
1077
+ end
1078
+
1079
+ private :trigger_id, :content_id, :list_id, :native_id, :input_id, :option_set
1080
+ private :selected_label, :root_attributes, :native_select, :width_classes, :trigger_button
1081
+ private :leading_content, :chips_frame, :content_attributes, :value_payload, :command_attributes
1082
+ private :input_attributes, :list_attributes, :empty_part, :loading_part, :status_part
1083
+ end
1084
+ end
1085
+ end
1086
+ end