alchemy_cms 8.3.11 → 8.4.1

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 (368) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +58 -10
  4. data/app/assets/builds/alchemy/admin/page-select.css +1 -1
  5. data/app/assets/builds/alchemy/admin/print.css +1 -1
  6. data/app/assets/builds/alchemy/admin/remote-select.css +1 -0
  7. data/app/assets/builds/alchemy/admin/tags-autocomplete.css +1 -0
  8. data/app/assets/builds/alchemy/admin.css +4 -4
  9. data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
  10. data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
  11. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  12. data/app/assets/builds/alchemy/light-theme.css +1 -1
  13. data/app/assets/builds/alchemy/preview.min.js +1 -1
  14. data/app/assets/builds/alchemy/theme.css +1 -1
  15. data/app/assets/builds/alchemy/welcome.css +1 -1
  16. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  17. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
  18. data/app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css +1 -1
  19. data/app/assets/builds/tinymce/skins/ui/alchemy-dark/skin.min.css +1 -1
  20. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  21. data/app/components/alchemy/admin/dashboard/widgets/greeting.html.erb +1 -1
  22. data/app/components/alchemy/admin/dashboard/widgets/news_reader.html.erb +38 -0
  23. data/app/components/alchemy/admin/dashboard/widgets/news_reader.rb +54 -0
  24. data/app/components/alchemy/admin/dashboard/widgets/online_users.html.erb +1 -1
  25. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.html.erb +1 -1
  26. data/app/components/alchemy/admin/dashboard/widgets/system_info.html.erb +11 -3
  27. data/app/components/alchemy/admin/dashboard/widgets/system_info.rb +6 -1
  28. data/app/components/alchemy/admin/element_editor.html.erb +1 -1
  29. data/app/components/alchemy/admin/element_select.rb +30 -20
  30. data/app/components/alchemy/admin/element_status_indicators.html.erb +16 -0
  31. data/app/components/alchemy/admin/element_status_indicators.rb +31 -0
  32. data/app/components/alchemy/admin/link_dialog/external_tab.rb +2 -1
  33. data/app/components/alchemy/admin/main_navigation/entry.rb +61 -0
  34. data/app/components/alchemy/admin/main_navigation/menu.rb +21 -0
  35. data/app/components/alchemy/admin/main_navigation/sub_navigation.rb +31 -0
  36. data/app/components/alchemy/admin/page_node.html.erb +1 -1
  37. data/app/components/alchemy/admin/page_permitted_roles_select.html.erb +11 -0
  38. data/app/components/alchemy/admin/page_permitted_roles_select.rb +35 -0
  39. data/app/components/alchemy/admin/page_select.rb +18 -8
  40. data/app/components/alchemy/admin/preview_time_select.rb +1 -1
  41. data/app/components/alchemy/admin/toolbar_button.rb +43 -20
  42. data/app/components/alchemy/admin/uploader_button.rb +66 -0
  43. data/app/components/alchemy/ingredients/base_editor.rb +10 -1
  44. data/app/components/alchemy/ingredients/boolean_editor.rb +1 -0
  45. data/app/components/alchemy/ingredients/color_editor.rb +6 -3
  46. data/app/components/alchemy/ingredients/datetime_view.rb +1 -1
  47. data/app/components/alchemy/ingredients/picture_view.rb +2 -4
  48. data/app/components/alchemy/menubar.html.erb +157 -0
  49. data/app/components/alchemy/menubar.rb +15 -0
  50. data/app/controllers/alchemy/admin/attachments_controller.rb +12 -9
  51. data/app/controllers/alchemy/admin/base_controller.rb +34 -21
  52. data/app/controllers/alchemy/admin/dashboard_controller.rb +4 -0
  53. data/app/controllers/alchemy/admin/elements_controller.rb +4 -23
  54. data/app/controllers/alchemy/admin/languages_controller.rb +1 -1
  55. data/app/controllers/alchemy/admin/nodes_controller.rb +1 -1
  56. data/app/controllers/alchemy/admin/pages_controller.rb +1 -1
  57. data/app/controllers/alchemy/admin/pictures_controller.rb +6 -3
  58. data/app/controllers/alchemy/admin/resources_controller.rb +5 -5
  59. data/app/controllers/alchemy/admin/sites_controller.rb +1 -1
  60. data/app/controllers/alchemy/base_controller.rb +24 -16
  61. data/app/controllers/alchemy/pages_controller.rb +29 -8
  62. data/app/controllers/alchemy/sitemap_controller.rb +58 -0
  63. data/app/controllers/concerns/alchemy/admin/csp_protection.rb +43 -0
  64. data/app/controllers/concerns/alchemy/admin/current_language.rb +1 -1
  65. data/app/controllers/concerns/alchemy/admin/resource_filter.rb +3 -1
  66. data/{lib → app/controllers/concerns}/alchemy/controller_actions.rb +13 -0
  67. data/app/controllers/concerns/alchemy/legacy_page_redirects.rb +4 -4
  68. data/app/helpers/alchemy/admin/base_helper.rb +4 -1
  69. data/app/helpers/alchemy/admin/form_helper.rb +0 -2
  70. data/app/helpers/alchemy/admin/navigation_helper.rb +10 -181
  71. data/app/helpers/alchemy/elements_block_helper.rb +14 -0
  72. data/app/helpers/alchemy/resources_helper.rb +15 -1
  73. data/app/javascript/alchemy_admin/components/attachment_select.js +11 -20
  74. data/app/javascript/alchemy_admin/components/auto_submit.js +2 -4
  75. data/app/javascript/alchemy_admin/components/button.js +0 -6
  76. data/app/javascript/alchemy_admin/components/clear_schedule_button.js +21 -0
  77. data/app/javascript/alchemy_admin/components/color_select.js +50 -45
  78. data/app/javascript/alchemy_admin/components/conditional_field.js +50 -0
  79. data/app/javascript/alchemy_admin/components/dirty_guard.js +77 -0
  80. data/app/javascript/alchemy_admin/components/dom_id_select.js +17 -10
  81. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +7 -2
  82. data/app/javascript/alchemy_admin/components/element_editor.js +29 -56
  83. data/app/javascript/alchemy_admin/components/element_select.js +26 -66
  84. data/app/javascript/alchemy_admin/components/icon.js +9 -4
  85. data/app/javascript/alchemy_admin/{image_cropper.js → components/image_cropper.js} +39 -53
  86. data/app/javascript/alchemy_admin/components/image_overlay_link.js +14 -0
  87. data/app/javascript/alchemy_admin/components/index.js +12 -1
  88. data/app/javascript/alchemy_admin/components/main_navi_entry.js +75 -0
  89. data/app/javascript/alchemy_admin/components/menu_node.js +73 -0
  90. data/app/javascript/alchemy_admin/components/news_reader.js +109 -0
  91. data/app/javascript/alchemy_admin/components/node_folder.js +36 -0
  92. data/app/javascript/alchemy_admin/components/node_form.js +34 -0
  93. data/app/javascript/alchemy_admin/components/node_select.js +25 -30
  94. data/app/javascript/alchemy_admin/components/node_tree.js +56 -0
  95. data/app/javascript/alchemy_admin/components/page_node.js +2 -1
  96. data/app/javascript/alchemy_admin/components/page_select.js +16 -34
  97. data/app/javascript/alchemy_admin/components/picture_archive.js +92 -0
  98. data/app/javascript/alchemy_admin/components/picture_editor.js +2 -1
  99. data/app/javascript/alchemy_admin/components/preview_window.js +11 -3
  100. data/app/javascript/alchemy_admin/components/remote_select.js +461 -81
  101. data/app/javascript/alchemy_admin/components/select.js +26 -88
  102. data/app/javascript/alchemy_admin/components/sitemap.js +2 -1
  103. data/app/javascript/alchemy_admin/components/sortable_elements.js +2 -1
  104. data/app/javascript/alchemy_admin/components/tags_autocomplete.js +63 -48
  105. data/app/javascript/alchemy_admin/components/tinymce.js +2 -1
  106. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +3 -2
  107. data/app/javascript/alchemy_admin/components/uploader.js +19 -3
  108. data/app/javascript/alchemy_admin/confirm_dialog.js +7 -1
  109. data/app/javascript/alchemy_admin/dialog.js +182 -110
  110. data/app/javascript/alchemy_admin/hotkeys.js +0 -5
  111. data/app/javascript/alchemy_admin/i18n.js +3 -9
  112. data/app/javascript/alchemy_admin/image_overlay.js +58 -42
  113. data/app/javascript/alchemy_admin/initializer.js +2 -2
  114. data/app/javascript/alchemy_admin/link_dialog.js +15 -2
  115. data/app/javascript/alchemy_admin/routes.js +18 -0
  116. data/app/javascript/alchemy_admin/turbo_stream_actions.js +88 -0
  117. data/app/javascript/alchemy_admin/utils/ajax.js +14 -3
  118. data/app/javascript/alchemy_admin/utils/events.js +5 -0
  119. data/app/javascript/alchemy_admin/utils/json_script.js +12 -0
  120. data/app/javascript/alchemy_admin/utils/tom_select.js +171 -0
  121. data/app/javascript/alchemy_admin.js +2 -19
  122. data/app/javascript/preview.js +131 -113
  123. data/app/jobs/alchemy/invalidate_elements_cache_job.rb +1 -1
  124. data/app/models/alchemy/admin/content_security_policy.rb +143 -0
  125. data/app/models/alchemy/admin/filters/base.rb +7 -0
  126. data/app/models/alchemy/admin/filters/datepicker.rb +1 -1
  127. data/app/models/alchemy/admin/filters/select.rb +20 -7
  128. data/app/models/alchemy/attachment.rb +14 -7
  129. data/app/models/alchemy/cache_control.rb +104 -0
  130. data/app/models/alchemy/current.rb +1 -1
  131. data/app/models/alchemy/eager_loading.rb +0 -2
  132. data/app/models/alchemy/element.rb +23 -8
  133. data/app/models/alchemy/element_definition.rb +1 -0
  134. data/app/models/alchemy/elements_repository.rb +11 -0
  135. data/app/models/alchemy/folded_page.rb +5 -1
  136. data/app/models/alchemy/ingredient.rb +26 -0
  137. data/app/models/alchemy/ingredients/datetime.rb +1 -1
  138. data/app/models/alchemy/ingredients/link.rb +2 -0
  139. data/app/models/alchemy/ingredients/picture.rb +3 -0
  140. data/app/models/alchemy/ingredients/text.rb +2 -0
  141. data/app/models/alchemy/language.rb +19 -7
  142. data/app/models/alchemy/legacy_page_url.rb +6 -1
  143. data/app/models/alchemy/node.rb +51 -0
  144. data/app/models/alchemy/page/page_natures.rb +41 -8
  145. data/app/models/alchemy/page/page_scopes.rb +23 -0
  146. data/app/models/alchemy/page/sitemap_cache_key.rb +56 -0
  147. data/app/models/alchemy/page.rb +88 -32
  148. data/app/models/alchemy/page_definition.rb +1 -1
  149. data/app/models/alchemy/page_mutex.rb +16 -0
  150. data/app/models/alchemy/page_version.rb +24 -1
  151. data/app/models/alchemy/permissions.rb +6 -5
  152. data/app/models/alchemy/picture.rb +15 -7
  153. data/app/models/alchemy/picture_description.rb +22 -0
  154. data/app/models/alchemy/picture_thumb.rb +18 -0
  155. data/app/models/alchemy/publishable/timestamp_resolver.rb +70 -0
  156. data/app/models/alchemy/resource.rb +1 -1
  157. data/app/models/alchemy/safe_url_validator.rb +34 -0
  158. data/app/models/alchemy/site.rb +8 -3
  159. data/app/models/alchemy/storage_adapter/active_storage/preprocessor.rb +7 -3
  160. data/app/models/alchemy/storage_adapter/active_storage.rb +4 -1
  161. data/app/models/alchemy/tag.rb +10 -4
  162. data/{lib → app/models/concerns}/alchemy/filetypes.rb +5 -0
  163. data/app/models/concerns/alchemy/picture_thumbnails.rb +61 -6
  164. data/app/models/concerns/alchemy/publishable.rb +14 -27
  165. data/app/models/concerns/alchemy/taggable.rb +92 -0
  166. data/app/models/concerns/alchemy/user_methods.rb +95 -0
  167. data/app/services/alchemy/duplicate_element.rb +5 -2
  168. data/app/services/alchemy/element_preloader.rb +1 -2
  169. data/app/stylesheets/alchemy/_custom-properties.scss +1 -0
  170. data/app/stylesheets/alchemy/_extends.scss +0 -8
  171. data/app/stylesheets/alchemy/_themes.scss +10 -9
  172. data/app/stylesheets/alchemy/admin/_empty-state.scss +29 -0
  173. data/app/stylesheets/alchemy/admin/_help.scss +10 -0
  174. data/app/stylesheets/alchemy/admin/_tom-select.scss +104 -15
  175. data/app/stylesheets/alchemy/admin/archive.scss +56 -11
  176. data/app/stylesheets/alchemy/admin/base.scss +4 -5
  177. data/app/stylesheets/alchemy/admin/buttons.scss +12 -1
  178. data/app/stylesheets/alchemy/admin/dashboard.scss +150 -20
  179. data/app/stylesheets/alchemy/admin/dialogs.scss +27 -36
  180. data/app/stylesheets/alchemy/admin/element-select.scss +1 -1
  181. data/app/stylesheets/alchemy/admin/elements.scss +8 -24
  182. data/app/stylesheets/alchemy/admin/flash.scss +2 -1
  183. data/app/stylesheets/alchemy/admin/forms.scss +60 -24
  184. data/app/stylesheets/alchemy/admin/frame.scss +40 -4
  185. data/app/stylesheets/alchemy/admin/image_library.scss +9 -12
  186. data/app/stylesheets/alchemy/admin/navigation.scss +17 -14
  187. data/app/stylesheets/alchemy/admin/node-select.scss +13 -37
  188. data/app/stylesheets/alchemy/admin/nodes.scss +1 -1
  189. data/app/stylesheets/alchemy/admin/page-select.scss +6 -97
  190. data/app/stylesheets/alchemy/admin/print.scss +0 -4
  191. data/app/stylesheets/alchemy/admin/remote-select.scss +170 -0
  192. data/app/stylesheets/alchemy/admin/search.scss +2 -3
  193. data/app/stylesheets/alchemy/admin/selects.scss +1 -294
  194. data/app/stylesheets/alchemy/admin/sitemap.scss +2 -1
  195. data/app/stylesheets/alchemy/admin/tables.scss +7 -1
  196. data/app/stylesheets/alchemy/admin/tags-autocomplete.scss +17 -0
  197. data/app/stylesheets/alchemy/admin/tags.scss +0 -13
  198. data/app/stylesheets/alchemy/admin/toolbar.scss +9 -6
  199. data/app/stylesheets/alchemy/admin/upload.scss +61 -1
  200. data/app/stylesheets/alchemy/admin.scss +4 -1
  201. data/app/types/alchemy/cache_control_type.rb +84 -0
  202. data/app/views/alchemy/_edit_mode.html.erb +1 -1
  203. data/app/views/alchemy/_menubar.html.erb +2 -154
  204. data/app/views/alchemy/_preview_mode_code.html.erb +0 -3
  205. data/app/views/alchemy/admin/_left_menu.html.erb +18 -5
  206. data/app/views/alchemy/admin/_top_menu.html.erb +1 -1
  207. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +5 -4
  208. data/app/views/alchemy/admin/attachments/_file_to_assign.html.erb +3 -3
  209. data/app/views/alchemy/admin/attachments/_files_list.html.erb +71 -83
  210. data/app/views/alchemy/admin/attachments/assign.turbo_stream.erb +8 -0
  211. data/app/views/alchemy/admin/attachments/index.html.erb +32 -7
  212. data/app/views/alchemy/admin/clipboard/insert.turbo_stream.erb +1 -1
  213. data/app/views/alchemy/admin/crop.html.erb +20 -20
  214. data/app/views/alchemy/admin/dashboard/_footer.html.erb +0 -3
  215. data/app/views/alchemy/admin/dashboard/_top.html.erb +1 -0
  216. data/app/views/alchemy/admin/dashboard/help.html.erb +84 -57
  217. data/app/views/alchemy/admin/dashboard/license.html.erb +6 -0
  218. data/app/views/alchemy/admin/elements/_footer.html.erb +0 -1
  219. data/app/views/alchemy/admin/elements/_form.html.erb +1 -1
  220. data/app/views/alchemy/admin/elements/_header.html.erb +2 -19
  221. data/app/views/alchemy/admin/elements/_preview_text_quote.html.erb +3 -0
  222. data/app/views/alchemy/admin/elements/_schedule.html.erb +7 -7
  223. data/app/views/alchemy/admin/elements/_toolbar.html.erb +1 -1
  224. data/app/views/alchemy/admin/elements/create.turbo_stream.erb +1 -1
  225. data/app/views/alchemy/admin/elements/index.html.erb +1 -1
  226. data/app/views/alchemy/admin/elements/publish.turbo_stream.erb +2 -2
  227. data/app/views/alchemy/admin/elements/update.turbo_stream.erb +31 -0
  228. data/app/views/alchemy/admin/ingredients/update.turbo_stream.erb +6 -3
  229. data/app/views/alchemy/admin/languages/_table.html.erb +0 -2
  230. data/app/views/alchemy/admin/languages/index.html.erb +25 -29
  231. data/app/views/alchemy/admin/layoutpages/_layoutpage.html.erb +1 -3
  232. data/app/views/alchemy/admin/layoutpages/edit.html.erb +1 -1
  233. data/app/views/alchemy/admin/layoutpages/index.html.erb +26 -7
  234. data/app/views/alchemy/admin/nodes/_form.html.erb +3 -21
  235. data/app/views/alchemy/admin/nodes/_node.html.erb +93 -88
  236. data/app/views/alchemy/admin/nodes/index.html.erb +15 -23
  237. data/app/views/alchemy/admin/pages/_create_language_form.html.erb +2 -6
  238. data/app/views/alchemy/admin/pages/_form.html.erb +2 -1
  239. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +1 -1
  240. data/app/views/alchemy/admin/pages/_table.html.erb +2 -4
  241. data/app/views/alchemy/admin/pages/_toolbar.html.erb +2 -1
  242. data/app/views/alchemy/admin/pages/edit.html.erb +10 -21
  243. data/app/views/alchemy/admin/pages/flush.turbo_stream.erb +1 -1
  244. data/app/views/alchemy/admin/pages/index.html.erb +26 -31
  245. data/app/views/alchemy/admin/pages/info.html.erb +2 -2
  246. data/app/views/alchemy/admin/pages/update.turbo_stream.erb +2 -2
  247. data/app/views/alchemy/admin/partials/_language_tree_select.html.erb +1 -1
  248. data/app/views/alchemy/admin/partials/_routes.html.erb +24 -48
  249. data/app/views/alchemy/admin/pictures/_archive.html.erb +3 -17
  250. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +1 -1
  251. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +5 -3
  252. data/app/views/alchemy/admin/pictures/_form.html.erb +0 -1
  253. data/app/views/alchemy/admin/pictures/_infos.html.erb +5 -1
  254. data/app/views/alchemy/admin/pictures/_picture.html.erb +1 -1
  255. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +3 -4
  256. data/app/views/alchemy/admin/pictures/assign.turbo_stream.erb +7 -0
  257. data/app/views/alchemy/admin/pictures/index.html.erb +38 -30
  258. data/app/views/alchemy/admin/pictures/show.html.erb +41 -41
  259. data/app/views/alchemy/admin/resources/_empty_state.html.erb +27 -0
  260. data/app/views/alchemy/admin/resources/_pagination.html.erb +1 -1
  261. data/app/views/alchemy/admin/resources/_per_page_select.html.erb +1 -1
  262. data/app/views/alchemy/admin/resources/_resource_table.html.erb +0 -2
  263. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +3 -2
  264. data/app/views/alchemy/admin/resources/index.html.erb +32 -13
  265. data/app/views/alchemy/admin/sites/index.html.erb +24 -22
  266. data/app/views/alchemy/admin/styleguide/index.html.erb +35 -12
  267. data/app/views/alchemy/admin/tags/index.html.erb +27 -25
  268. data/app/views/alchemy/admin/tinymce/_setup.html.erb +6 -8
  269. data/app/views/alchemy/admin/translations/_en.js +3 -0
  270. data/app/views/alchemy/admin/uploader/_button.html.erb +11 -17
  271. data/app/views/alchemy/admin/uploader/_setup.html.erb +3 -3
  272. data/app/views/alchemy/pages/sitemap.xml.erb +2 -0
  273. data/app/views/kaminari/alchemy/_first_page.html.erb +1 -2
  274. data/app/views/kaminari/alchemy/_gap.html.erb +0 -1
  275. data/app/views/kaminari/alchemy/_last_page.html.erb +1 -2
  276. data/app/views/kaminari/alchemy/_next_page.html.erb +1 -2
  277. data/app/views/kaminari/alchemy/_page.html.erb +1 -2
  278. data/app/views/kaminari/alchemy/_paginator.html.erb +0 -1
  279. data/app/views/kaminari/alchemy/_prev_page.html.erb +1 -2
  280. data/app/views/layouts/alchemy/admin/dialog.html.erb +3 -0
  281. data/app/views/layouts/alchemy/admin.html.erb +5 -6
  282. data/config/brakeman.ignore +23 -1
  283. data/config/importmap.rb +10 -8
  284. data/config/initializers/dragonfly.rb +21 -17
  285. data/config/locales/alchemy.en.yml +52 -11
  286. data/config/routes.rb +3 -3
  287. data/db/migrate/20260727120000_add_permitted_roles_to_alchemy_pages.rb +23 -0
  288. data/db/migrate/20260820120000_add_cached_tag_list_to_taggables.rb +57 -0
  289. data/lib/alchemy/configuration/number_option.rb +13 -0
  290. data/lib/alchemy/configuration/proc_option.rb +13 -0
  291. data/lib/alchemy/configuration.rb +2 -0
  292. data/lib/alchemy/configurations/admin_components.rb +30 -0
  293. data/lib/alchemy/configurations/format_matchers.rb +7 -1
  294. data/lib/alchemy/configurations/main.rb +103 -4
  295. data/lib/alchemy/configurations/menu.rb +91 -0
  296. data/lib/alchemy/configurations/menu_item.rb +123 -0
  297. data/lib/alchemy/configurations/sitemap.rb +1 -0
  298. data/lib/alchemy/configurations/uploader.rb +15 -1
  299. data/lib/alchemy/engine.rb +0 -6
  300. data/lib/alchemy/i18n.rb +26 -0
  301. data/lib/alchemy/modules.rb +52 -50
  302. data/lib/alchemy/tasks/generate_thumbnails.rb +99 -0
  303. data/lib/alchemy/tasks/tidy.rb +1 -1
  304. data/lib/alchemy/test_support/capybara_helpers.rb +48 -37
  305. data/lib/alchemy/test_support/current_language_shared_examples.rb +1 -1
  306. data/lib/alchemy/test_support/factories/dummy_user_factory.rb +4 -1
  307. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +132 -0
  308. data/lib/alchemy/test_support/shared_ingredient_examples.rb +52 -0
  309. data/lib/alchemy/test_support/shared_publishable_examples.rb +29 -4
  310. data/lib/alchemy/upgrader/eight_four.rb +237 -0
  311. data/lib/alchemy/upgrader/register_module_rewriter.rb +177 -0
  312. data/lib/alchemy/upgrader.rb +2 -1
  313. data/lib/alchemy/version.rb +1 -1
  314. data/lib/alchemy.rb +0 -1
  315. data/lib/alchemy_cms.rb +0 -13
  316. data/lib/generators/alchemy/elements/templates/view.html.erb +1 -1
  317. data/lib/generators/alchemy/elements/templates/view.html.haml +1 -1
  318. data/lib/generators/alchemy/elements/templates/view.html.slim +1 -1
  319. data/lib/generators/alchemy/install/install_generator.rb +2 -13
  320. data/lib/generators/alchemy/install/templates/alchemy.rb.tt +24 -9
  321. data/lib/generators/alchemy/module/module_generator.rb +1 -0
  322. data/lib/generators/alchemy/module/templates/ability.rb.tt +1 -1
  323. data/lib/generators/alchemy/module/templates/module_config.rb.tt +16 -18
  324. data/lib/generators/alchemy/user_columns_migration/templates/migration.rb.tt +19 -0
  325. data/lib/generators/alchemy/user_columns_migration/user_columns_migration_generator.rb +30 -0
  326. data/lib/tasks/alchemy/thumbnails.rake +16 -29
  327. data/lib/tasks/alchemy/upgrade.rake +45 -3
  328. data/vendor/javascript/cropperjs.min.js +3 -3
  329. data/vendor/javascript/floating-ui.min.js +1 -1
  330. data/vendor/javascript/tinymce.min.js +5 -5
  331. data/vendor/javascript/tom-select.min.js +1 -1
  332. metadata +85 -65
  333. data/app/javascript/alchemy_admin/components/action.js +0 -50
  334. data/app/javascript/alchemy_admin/dirty.js +0 -55
  335. data/app/javascript/alchemy_admin/node_tree.js +0 -82
  336. data/app/javascript/alchemy_admin/picture_selector.js +0 -67
  337. data/app/javascript/alchemy_admin/templates/compiled.js +0 -1
  338. data/app/javascript/alchemy_admin/templates/node_folder.hbs +0 -3
  339. data/app/stylesheets/alchemy/admin/attachment-select.scss +0 -19
  340. data/app/views/alchemy/admin/_main_navi.html.erb +0 -6
  341. data/app/views/alchemy/admin/attachments/assign.js.erb +0 -15
  342. data/app/views/alchemy/admin/pages/_page_layout_filter.html.erb +0 -30
  343. data/app/views/alchemy/admin/partials/_main_navigation_entry.html.erb +0 -20
  344. data/app/views/alchemy/admin/partials/_sub_navigation.html.erb +0 -11
  345. data/app/views/alchemy/admin/pictures/assign.js.erb +0 -13
  346. data/app/views/alchemy/base/error_notice.js.erb +0 -1
  347. data/app/views/alchemy/base/permission_denied.js.erb +0 -3
  348. data/app/views/alchemy/base/redirect.js.erb +0 -11
  349. data/config/alchemy/config.yml +0 -217
  350. data/config/alchemy/modules.yml +0 -82
  351. data/config/initializers/rails_live_reload.rb +0 -13
  352. data/lib/alchemy/dev_support/live_reload_watcher.rb +0 -5
  353. data/lib/alchemy/taggable.rb +0 -49
  354. data/lib/generators/alchemy/install/templates/dragonfly.rb.tt +0 -34
  355. data/vendor/assets/stylesheets/alchemy_admin/select2.css +0 -1
  356. data/vendor/javascript/handlebars.min.js +0 -29
  357. data/vendor/javascript/jquery.min.js +0 -2
  358. data/vendor/javascript/rails-ujs.min.js +0 -1
  359. data/vendor/javascript/select2.min.js +0 -23
  360. /data/{lib → app/controllers/concerns}/alchemy/ability_helper.rb +0 -0
  361. /data/{lib → app/controllers/concerns}/alchemy/admin/locale.rb +0 -0
  362. /data/{lib → app/controllers/concerns}/alchemy/admin/preview_time.rb +0 -0
  363. /data/{lib → app/controllers/concerns}/alchemy/admin/timezone.rb +0 -0
  364. /data/{lib → app/controllers/concerns}/alchemy/configuration_methods.rb +0 -0
  365. /data/{lib → app/models}/alchemy/admin/preview_url.rb +0 -0
  366. /data/{lib → app/models/concerns}/alchemy/hints.rb +0 -0
  367. /data/{lib → app/models/concerns}/alchemy/name_conversions.rb +0 -0
  368. /data/{lib → app/services}/alchemy/elements_finder.rb +0 -0
@@ -1,19 +1,14 @@
1
- import { setupSelectLocale } from "alchemy_admin/i18n"
2
-
3
- /**
4
- * Escapes a string for use within HTML. Select2 hands the return value of
5
- * `formatResult`/`formatSelection` straight to jQuery, so everything woven into
6
- * those templates has to be escaped here.
7
- * @param {string} value
8
- * @returns {string}
9
- */
10
- export function escapeHtml(value) {
11
- return `${value}`
12
- .replace(/&/g, "&")
13
- .replace(/</g, "&lt;")
14
- .replace(/>/g, "&gt;")
15
- .replace(/"/g, "&quot;")
16
- }
1
+ import TomSelect from "tom-select"
2
+ import "alchemy_admin/components/icon"
3
+ import { escape_html } from "tom-select/utils"
4
+ import { growl } from "alchemy_admin/growler"
5
+ import { translate } from "alchemy_admin/i18n"
6
+ import {
7
+ createDropdownPositioning,
8
+ dropdownMessages,
9
+ focusTomSelect,
10
+ teardownTomSelect
11
+ } from "alchemy_admin/utils/tom_select"
17
12
 
18
13
  /**
19
14
  * Escapes a string for literal use inside a `RegExp`, so a term with
@@ -27,10 +22,10 @@ export function escapeRegExp(value) {
27
22
  }
28
23
 
29
24
  export function hightlightTerm(name, term) {
30
- // The result is rendered raw as the entry's name, so the name must be escaped
31
- // before the term markup is woven in — otherwise a crafted name (e.g. a menu
32
- // node title) is a stored XSS sink.
33
- const escapedName = escapeHtml(name)
25
+ // The result is rendered raw as the option's primary text, so the name must
26
+ // be escaped before the term markup is woven in — otherwise a crafted name
27
+ // (e.g. a menu node title) is a stored XSS sink.
28
+ const escapedName = escape_html(name)
34
29
  if (!term) return escapedName
35
30
  return escapedName.replace(
36
31
  new RegExp(escapeRegExp(term), "gi"),
@@ -38,43 +33,101 @@ export function hightlightTerm(name, term) {
38
33
  )
39
34
  }
40
35
 
41
- export class RemoteSelect extends HTMLElement {
42
- #select2 = null
43
-
44
- async connectedCallback() {
45
- await setupSelectLocale()
46
- // Bail out if the element was disconnected while the locale was loading.
47
- // Otherwise Select2 would leak onto a detached input.
48
- if (!this.isConnected) return
36
+ /**
37
+ * Serializes a (possibly nested) params object into a Rails/Ransack style query
38
+ * string, e.g. `{ q: { name_cont: "x" }, page: 1 }` => `q%5Bname_cont%5D=x&page=1`.
39
+ * @param {object} params
40
+ * @param {string} [prefix]
41
+ * @returns {string}
42
+ */
43
+ export function serializeParams(params, prefix) {
44
+ return Object.keys(params)
45
+ .map((key) => {
46
+ const value = params[key]
47
+ const scopedKey = prefix ? `${prefix}[${key}]` : key
48
+ if (value === null || value === undefined) return ""
49
+ if (Array.isArray(value)) {
50
+ return value
51
+ .map(
52
+ (item) =>
53
+ `${encodeURIComponent(`${scopedKey}[]`)}=${encodeURIComponent(item)}`
54
+ )
55
+ .join("&")
56
+ }
57
+ if (typeof value === "object") {
58
+ return serializeParams(value, scopedKey)
59
+ }
60
+ return `${encodeURIComponent(scopedKey)}=${encodeURIComponent(value)}`
61
+ })
62
+ .filter((part) => part !== "")
63
+ .join("&")
64
+ }
49
65
 
50
- this.input.classList.add("alchemy_selectbox")
66
+ export class RemoteSelect extends HTMLElement {
67
+ #tomSelect = null
68
+ // The currently selected item, used to compute the added/removed delta that
69
+ // consumers of the RemoteSelect.Change event rely on.
70
+ #selectedItem = null
71
+ // In multiple mode the same delta is computed against this map of the
72
+ // currently selected records, keyed by their id.
73
+ #selectedItems = new Map()
51
74
 
52
- this.#select2 = $(this.input)
53
- .select2(this.select2Config)
54
- .on("select2-open", this.#onOpen)
55
- .on("change", this.#onChange)
75
+ connectedCallback() {
76
+ const element = this.input
77
+ element.classList.add("alchemy_selectbox")
78
+ if (element instanceof HTMLInputElement) {
79
+ // Preselection is driven by the `selection` attribute, not by the input's
80
+ // value. Clear the value during setup so Tom Select does not turn it into a
81
+ // stray option and does not overwrite it with the selected id — the link
82
+ // dialog renders a URL in this input and reads it back. Restore it afterwards.
83
+ const inputValue = element.value
84
+ element.value = ""
85
+ this.#tomSelect = new TomSelect(element, this.tomSelectConfig)
86
+ element.value = inputValue
87
+ } else {
88
+ // A multiple select wraps a <select>, which has no such value to protect.
89
+ this.#tomSelect = new TomSelect(element, this.tomSelectConfig)
90
+ }
91
+ // A spinner shown inside the control while results load. Tom Select toggles
92
+ // the wrapper's `loading` class, which reveals it (see the stylesheet).
93
+ this.#tomSelect.control.append(document.createElement("sl-spinner"))
94
+ // Open the dropdown on click, mirroring the native <alchemy-select> (Tom
95
+ // Select does not open on click on its own once openOnFocus is off).
96
+ this.#tomSelect.control.addEventListener(
97
+ "click",
98
+ this.#tomSelect.open.bind(this.#tomSelect)
99
+ )
56
100
  }
57
101
 
58
102
  disconnectedCallback() {
59
- if (this.#select2) {
60
- this.#select2.off("select2-open", this.#onOpen)
61
- this.#select2.off("change", this.#onChange)
62
- this.#select2.select2("destroy")
63
- this.#select2 = null
64
- }
103
+ teardownTomSelect(this.#tomSelect)
104
+ this.#tomSelect = null
65
105
  }
66
106
 
67
- #onOpen = (evt) => this.onOpen(evt)
68
- #onChange = (evt) => this.onChange(evt)
107
+ focus() {
108
+ focusTomSelect(this.#tomSelect, () => super.focus())
109
+ }
69
110
 
70
111
  /**
71
- * Optional on change handler called by Select2.
72
- * @param {Event} event
112
+ * Optional on change handler.
113
+ * @param {{added: ?object, removed: ?object}} event
73
114
  */
74
115
  onChange(event) {
75
- // Update selection attribute so re-attaching the select2 component to
76
- // the same input (e.g. after dragndrop) does not reset the selection.
77
- if (event.added) {
116
+ // Update selection attribute so re-attaching the component to the same
117
+ // input (e.g. after dragndrop) does not reset the selection. In multiple
118
+ // mode it holds the array of the currently selected records.
119
+ if (this.multiple) {
120
+ if (event.added) {
121
+ this.#selectedItems.set(String(event.added.id), event.added)
122
+ }
123
+ if (event.removed) {
124
+ this.#selectedItems.delete(String(event.removed.id))
125
+ }
126
+ this.setAttribute(
127
+ "selection",
128
+ JSON.stringify([...this.#selectedItems.values()])
129
+ )
130
+ } else if (event.added) {
78
131
  this.setAttribute("selection", JSON.stringify(event.added))
79
132
  }
80
133
  this.dispatchCustomEvent("RemoteSelect.Change", {
@@ -84,18 +137,10 @@ export class RemoteSelect extends HTMLElement {
84
137
  }
85
138
 
86
139
  /**
87
- * Optional on open handler called by Select2.
88
- * @param {Event} event
140
+ * Optional on open handler, called when the dropdown opens.
141
+ * @param {Event} [event]
89
142
  */
90
- onOpen(event) {
91
- // add focus to the search input. Select2 is handling the focus on the first opening,
92
- // but it does not work the second time. One process in select2 is "stealing" the focus
93
- // if the command is not delayed. It is an intermediate solution until we are going to
94
- // move away from Select2
95
- setTimeout(() => {
96
- document.querySelector("#select2-drop .select2-input").focus()
97
- }, 100)
98
- }
143
+ onOpen() {}
99
144
 
100
145
  /**
101
146
  * Dispatches a custom event with given name, namespaced under `Alchemy.`.
@@ -113,6 +158,10 @@ export class RemoteSelect extends HTMLElement {
113
158
  return this.hasAttribute("allow-clear")
114
159
  }
115
160
 
161
+ get multiple() {
162
+ return this.hasAttribute("multiple")
163
+ }
164
+
116
165
  get selection() {
117
166
  return this.getAttribute("selection")
118
167
  }
@@ -130,27 +179,132 @@ export class RemoteSelect extends HTMLElement {
130
179
  }
131
180
 
132
181
  get input() {
133
- return this.getElementsByTagName("input")[0]
182
+ // The wrapped control is an <input> in single mode and a <select> in
183
+ // multiple mode. Tom Select keeps the original element in place and inserts
184
+ // its own control after it, so the original always comes first.
185
+ return this.querySelector("input, select")
134
186
  }
135
187
 
136
- get select2Config() {
188
+ /**
189
+ * Tom Select configuration.
190
+ * @returns {object}
191
+ */
192
+ get tomSelectConfig() {
193
+ const self = this
194
+ const ajax = this.ajaxConfig
195
+ const plugins = { virtual_scroll: {} }
196
+
197
+ if (this.multiple) {
198
+ plugins.remove_button = {}
199
+ }
200
+
201
+ if (this.allowClear) {
202
+ plugins.clear_button = {
203
+ html() {
204
+ return `<button type="button" class="clear-button" aria-label="${translate(
205
+ "Clear selection"
206
+ )}">
207
+ <alchemy-icon name="close" size="1x"></alchemy-icon>
208
+ </button>`
209
+ }
210
+ }
211
+ }
212
+
213
+ const { onDropdownOpen, onDropdownClose } = createDropdownPositioning()
214
+
137
215
  return {
216
+ plugins,
217
+ // The server returns each item with an `id` used as the option value.
218
+ valueField: "id",
219
+ // A single item unless the multiple attribute lifts the limit.
220
+ maxItems: this.multiple ? null : 1,
221
+ // Close the dropdown after selecting a single item; in multiple mode keep
222
+ // it open so several items can be picked in a row.
223
+ closeAfterSelect: !this.multiple,
224
+ // Show every item returned by the server, not just the first 50.
225
+ maxOptions: null,
226
+ // Searching is done on the server, so keep every returned option.
227
+ searchField: [],
228
+ // Debounce the server requests while typing.
229
+ loadThrottle: ajax.quietMillis,
230
+ // Refresh the input state (hide the selection, show the loading dropdown)
231
+ // on every keystroke without delay. The server request stays debounced by
232
+ // loadThrottle, so this only affects the immediate UI feedback.
233
+ refreshThrottle: 0,
138
234
  placeholder: this.placeholder,
139
- allowClear: this.allowClear,
140
- initSelection: (_$el, callback) => {
141
- if (this.selection) {
142
- callback(JSON.parse(this.selection))
235
+ // Behave like the local <alchemy-select>: focusing neither opens the
236
+ // dropdown nor fires a search. The first page of results is loaded when
237
+ // the dropdown opens (see onDropdownOpen), i.e. on click or typing.
238
+ openOnFocus: false,
239
+ // Load even for an empty search term to show the initial result list.
240
+ shouldLoad: () => true,
241
+ firstUrl: (query) => this.#requestUrl(query, 1),
242
+ load: (query, callback) => this.#load(query, callback),
243
+ onInitialize() {
244
+ if (!self.selection) return
245
+ // The preselection only carries enough data to label an item. Mark each
246
+ // one, so the option template can tell it apart from the complete records
247
+ // the server returns. Loading the record drops the mark again.
248
+ if (self.multiple) {
249
+ const items = JSON.parse(self.selection)
250
+ // Track the preselected items first, so the change handler recognizes
251
+ // them as unchanged. Adding them silently avoids a spurious change
252
+ // event while still rendering the items and marking the options.
253
+ self.#selectedItems = new Map(
254
+ items.map((item) => [String(item.id), item])
255
+ )
256
+ items.forEach((item) => {
257
+ this.addOption({ ...item, $preselection: true })
258
+ this.addItem(item.id, true)
259
+ })
260
+ } else {
261
+ const item = JSON.parse(self.selection)
262
+ // Track the preselected item first, so the change handler recognizes
263
+ // it as unchanged. Adding it silently avoids a spurious change event
264
+ // while still rendering the item and marking the option.
265
+ self.#selectedItem = item
266
+ this.addOption({ ...item, $preselection: true })
267
+ this.addItem(item.id, true)
268
+ }
269
+ },
270
+ onChange(value) {
271
+ self.#onChange(value, this)
272
+ },
273
+ onType(term) {
274
+ // While typing, give the search input an opaque background so it covers
275
+ // the selected item behind it. Without this the previous selection shows
276
+ // through the search box (see the `input-active` styles).
277
+ this.control_input.classList.toggle("has-value", term.length > 0)
278
+ },
279
+ onDropdownOpen() {
280
+ onDropdownOpen.call(this)
281
+ if (!this.loading) {
282
+ this.load(this.lastValue ?? "")
143
283
  }
284
+ self.onOpen()
144
285
  },
145
- ajax: this.ajaxConfig,
146
- formatSelection: (item) => this._renderResult(item),
147
- formatResult: (item, _el, query) =>
148
- this._renderListEntry(item, query.term)
286
+ onDropdownClose,
287
+ render: {
288
+ option(item, _escape) {
289
+ // A preselection that the server has not returned yet only carries
290
+ // enough data to label the item, so render it like the control does.
291
+ // Everything else is a complete record and gets the list entry.
292
+ if (item.$preselection) {
293
+ return `<div>${self._renderResult(item)}</div>`
294
+ }
295
+ return self._renderListEntry(item, this.lastValue)
296
+ },
297
+ item(item, _escape) {
298
+ return `<div>${self._renderResult(item)}</div>`
299
+ },
300
+ ...dropdownMessages
301
+ }
149
302
  }
150
303
  }
151
304
 
152
305
  /**
153
- * Ajax configuration for Select2
306
+ * Ajax configuration. Kept for API compatibility with subclasses that spread
307
+ * `super.ajaxConfig` and augment it (e.g. adding request `params`).
154
308
  * @returns {object}
155
309
  */
156
310
  get ajaxConfig() {
@@ -164,7 +318,123 @@ export class RemoteSelect extends HTMLElement {
164
318
  }
165
319
 
166
320
  /**
167
- * Search query send to server from select2
321
+ * Builds the request url for a given search term and page.
322
+ * @param {string} term
323
+ * @param {number} page
324
+ * @returns {string}
325
+ * @private
326
+ */
327
+ #requestUrl(term, page) {
328
+ const query = serializeParams(this.ajaxConfig.data(term, page))
329
+ const separator = this.url.includes("?") ? "&" : "?"
330
+ return query ? `${this.url}${separator}${query}` : this.url
331
+ }
332
+
333
+ /**
334
+ * Loads results from the server and registers the next page url for the
335
+ * virtual scroll plugin.
336
+ * @param {string} query
337
+ * @param {function} callback
338
+ * @private
339
+ */
340
+ async #load(query, callback) {
341
+ const ajax = this.ajaxConfig
342
+ const url = this.#tomSelect.getUrl(query)
343
+ const page = Number(
344
+ new URL(url, window.location.origin).searchParams.get("page")
345
+ )
346
+ try {
347
+ const response = await fetch(url, {
348
+ headers: { Accept: "application/json", ...(ajax.params?.headers ?? {}) }
349
+ })
350
+ // fetch only rejects on a network error, so an error response has to be
351
+ // turned into one. Its body often parses as JSON just fine and would
352
+ // otherwise reach the callback as an empty result set.
353
+ if (!response.ok) {
354
+ throw new Error(`${response.status} ${response.statusText}`)
355
+ }
356
+ const { results, more } = ajax.results(await response.json())
357
+ if (more) {
358
+ this.#tomSelect.setNextUrl(query, this.#requestUrl(query, page + 1))
359
+ }
360
+ callback(results)
361
+ } catch (error) {
362
+ growl(error.message || error, "error")
363
+ console.error(`[alchemy] Loading ${url} failed`, error)
364
+ // Tom Select only clears its loading state once the callback ran.
365
+ callback()
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Computes the added/removed delta from a Tom Select change and forwards it to
371
+ * the `onChange` handler.
372
+ * @param {string} value
373
+ * @param {TomSelect} tomSelect
374
+ * @private
375
+ */
376
+ #onChange(value, tomSelect) {
377
+ if (this.multiple) {
378
+ this.#onChangeMultiple(value, tomSelect)
379
+ return
380
+ }
381
+ const previous = this.#selectedItem
382
+ if (value === "" || value == null) {
383
+ if (!previous) return
384
+ this.#selectedItem = null
385
+ this.onChange({ added: null, removed: previous })
386
+ } else if (!previous || String(previous.id) !== String(value)) {
387
+ const added = this.#itemData(tomSelect.options[value])
388
+ this.#selectedItem = added
389
+ this.onChange({ added, removed: previous })
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Computes the per item added/removed deltas from a multiple Tom Select change
395
+ * (whose value is the array of selected ids) by diffing it against the tracked
396
+ * selection, and forwards each delta to the `onChange` handler.
397
+ * @param {string[]} values
398
+ * @param {TomSelect} tomSelect
399
+ * @private
400
+ */
401
+ #onChangeMultiple(values, tomSelect) {
402
+ const ids = (values ?? []).map(String)
403
+ const previous = new Set(this.#selectedItems.keys())
404
+ const current = new Set(ids)
405
+ previous.forEach((id) => {
406
+ if (!current.has(id)) {
407
+ this.onChange({ added: null, removed: this.#selectedItems.get(id) })
408
+ }
409
+ })
410
+ ids.forEach((id) => {
411
+ if (!previous.has(id)) {
412
+ this.onChange({
413
+ added: this.#itemData(tomSelect.options[id]),
414
+ removed: null
415
+ })
416
+ }
417
+ })
418
+ }
419
+
420
+ /**
421
+ * An option also carries the bookkeeping Tom Select keeps on it, among it the
422
+ * node it was rendered into. Serializing that into the selection attribute
423
+ * breaks the render cache once a component is attached to it again, so keep
424
+ * the record itself only.
425
+ * @param {?object} option
426
+ * @returns {?object}
427
+ * @private
428
+ */
429
+ #itemData(option) {
430
+ if (!option) return null
431
+ return Object.fromEntries(
432
+ Object.entries(option).filter(([key]) => !key.startsWith("$"))
433
+ )
434
+ }
435
+
436
+ /**
437
+ * Search query send to server.
168
438
  * @param {string} term
169
439
  * @param {number} page
170
440
  * @returns {object}
@@ -173,7 +443,7 @@ export class RemoteSelect extends HTMLElement {
173
443
  _searchQuery(term, page) {
174
444
  return {
175
445
  q: {
176
- name_cont: term,
446
+ name_i_cont: term,
177
447
  ...JSON.parse(this.queryParams)
178
448
  },
179
449
  page: page
@@ -181,9 +451,9 @@ export class RemoteSelect extends HTMLElement {
181
451
  }
182
452
 
183
453
  /**
184
- * Parses server response into select2 results object
454
+ * Parses server response into a results object.
185
455
  * @param {object} response
186
- * @returns {object}
456
+ * @returns {{results: Array, more: boolean}}
187
457
  * @private
188
458
  */
189
459
  _parseResponse(response) {
@@ -195,30 +465,140 @@ export class RemoteSelect extends HTMLElement {
195
465
  }
196
466
 
197
467
  /**
198
- * result which is visible if a page was selected
468
+ * Renders a dropdown option from the slots a subclass describes in `_entry`.
469
+ * External subclasses that still override `_renderListEntry` directly keep
470
+ * their own markup and never reach this.
199
471
  * @param {object} item
472
+ * @param {string} term
200
473
  * @returns {string}
201
474
  * @private
202
475
  */
203
- _renderResult() {
204
- throw new Error(
205
- "You need to define a _renderResult function on your sub class!"
206
- )
476
+ _renderListEntry(item, term) {
477
+ return this.#renderEntry(this._entry(item, term))
207
478
  }
208
479
 
209
480
  /**
210
- * html template for each list entry
481
+ * Renders the selected item (shown in the control) from the slots a subclass
482
+ * describes in `_selectedEntry`. External subclasses that still override
483
+ * `_renderResult` directly keep their own markup and never reach this.
211
484
  * @param {object} item
212
- * @param {string} term
213
485
  * @returns {string}
214
486
  * @private
215
487
  */
216
- _renderListEntry() {
488
+ _renderResult(item) {
489
+ return this.#renderSelection(this._selectedEntry(item))
490
+ }
491
+
492
+ /**
493
+ * Describes the slots of a dropdown option. Subclasses override this and
494
+ * return an object with any of `icon`/`media`, `primary`, `aside`,
495
+ * `secondary`, and `secondaryAside`. See `#renderEntry` for the accepted
496
+ * value shapes.
497
+ * @param {object} item
498
+ * @param {string} term
499
+ * @returns {object}
500
+ * @protected
501
+ */
502
+ _entry() {
503
+ throw new Error("You need to define an _entry function on your sub class!")
504
+ }
505
+
506
+ /**
507
+ * Describes the slots of the selected item shown in the control. Subclasses
508
+ * override this and return an object with `icon`/`media`, `primary`, and
509
+ * `secondary`.
510
+ * @param {object} item
511
+ * @returns {object}
512
+ * @protected
513
+ */
514
+ _selectedEntry() {
217
515
  throw new Error(
218
- "You need to define a _renderListEntry function on your sub class!"
516
+ "You need to define a _selectedEntry function on your sub class!"
219
517
  )
220
518
  }
221
519
 
520
+ /**
521
+ * Builds the two row grid markup of a dropdown option from its slots.
522
+ * @param {object} slots
523
+ * @returns {string}
524
+ * @private
525
+ */
526
+ #renderEntry(slots) {
527
+ return `
528
+ <div class="remote-select--entry">
529
+ ${this.#leadColumn(slots)}
530
+ ${this.#cell("remote-select--primary", slots.primary, { raw: true })}
531
+ ${this.#cell("remote-select--aside", slots.aside)}
532
+ ${this.#cell("remote-select--secondary", slots.secondary)}
533
+ ${this.#cell("remote-select--secondary-aside", slots.secondaryAside)}
534
+ </div>
535
+ `
536
+ }
537
+
538
+ /**
539
+ * Builds the single row markup of the selected item from its slots. Only the
540
+ * leading column, the primary text, and the (fast shrinking) secondary text
541
+ * are shown; the compact control has no room for more.
542
+ * @param {object} slots
543
+ * @returns {string}
544
+ * @private
545
+ */
546
+ #renderSelection(slots) {
547
+ return `
548
+ <div class="remote-select--selection">
549
+ ${this.#leadColumn(slots)}
550
+ ${this.#cell("remote-select--selection-name", slots.primary, { raw: true })}
551
+ ${this.#cell("remote-select--selection-aside", slots.secondary)}
552
+ </div>
553
+ `
554
+ }
555
+
556
+ /**
557
+ * Builds the leading column, either an icon or a media thumbnail. The two are
558
+ * mutually exclusive; the icon wins if a subclass passes both.
559
+ * @param {object} slots
560
+ * @returns {string}
561
+ * @private
562
+ */
563
+ #leadColumn(slots) {
564
+ if (slots.icon) {
565
+ return `<alchemy-icon class="remote-select--icon" name="${escape_html(slots.icon)}"></alchemy-icon>`
566
+ }
567
+ if (slots.media) {
568
+ return `<img class="remote-select--media" src="${escape_html(slots.media)}" alt="">`
569
+ }
570
+ return ""
571
+ }
572
+
573
+ /**
574
+ * Renders a single cell from a slot value. Omitted or empty values render
575
+ * nothing. A string is escaped, unless `raw` is set (the primary text is
576
+ * pre-highlighted HTML). `{ text, truncate: "head" }` wraps the text in
577
+ * `<bdi>` and truncates it from the head. `{ badge }` renders the pill style.
578
+ * @param {string} className
579
+ * @param {(string|object)} value
580
+ * @param {{raw?: boolean}} [options]
581
+ * @returns {string}
582
+ * @private
583
+ */
584
+ #cell(className, value, { raw = false } = {}) {
585
+ if (value == null || value === "") return ""
586
+
587
+ if (typeof value === "object") {
588
+ if (value.badge != null && value.badge !== "") {
589
+ return `<span class="${className} remote-select--badge">${escape_html(value.badge)}</span>`
590
+ }
591
+ if (value.text == null || value.text === "") return ""
592
+ const text = raw ? value.text : escape_html(value.text)
593
+ if (value.truncate === "head") {
594
+ return `<span class="${className} remote-select--truncate-head"><bdi>${text}</bdi></span>`
595
+ }
596
+ return `<span class="${className}">${text}</span>`
597
+ }
598
+
599
+ return `<span class="${className}">${raw ? value : escape_html(value)}</span>`
600
+ }
601
+
222
602
  /**
223
603
  * hightlighted search term
224
604
  * @param {string} name