plutonium 0.64.0 → 0.65.0

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 (278) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +4 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +14 -0
  4. data/.claude/skills/plutonium-dashboard/SKILL.md +161 -0
  5. data/.claude/skills/plutonium-resource/SKILL.md +94 -14
  6. data/.claude/skills/plutonium-ui/SKILL.md +22 -1
  7. data/CHANGELOG.md +59 -0
  8. data/CLAUDE.md +282 -210
  9. data/SECURITY.md +1 -1
  10. data/app/assets/plutonium-charts.js +20604 -0
  11. data/app/assets/plutonium-charts.js.map +7 -0
  12. data/app/assets/plutonium-charts.min.js +41 -0
  13. data/app/assets/plutonium-charts.min.js.map +7 -0
  14. data/app/assets/plutonium.css +1 -1
  15. data/app/assets/plutonium.js +794 -629
  16. data/app/assets/plutonium.js.map +4 -4
  17. data/app/assets/plutonium.min.js +48 -48
  18. data/app/assets/plutonium.min.js.map +4 -4
  19. data/app/views/plutonium/_flash_alerts.html.erb +2 -2
  20. data/app/views/plutonium/_resource_header.html.erb +2 -2
  21. data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
  22. data/app/views/plutonium/_toast.html.erb +2 -2
  23. data/app/views/rodauth/_login_form.html.erb +1 -1
  24. data/app/views/rodauth/_password_visibility.html.erb +1 -1
  25. data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
  26. data/app/views/rodauth/change_login.html.erb +2 -2
  27. data/app/views/rodauth/create_account.html.erb +4 -4
  28. data/app/views/rodauth/reset_password_request.html.erb +1 -1
  29. data/app/views/rodauth/verify_account_resend.html.erb +1 -1
  30. data/app/views/rodauth/webauthn_remove.html.erb +1 -1
  31. data/config/brakeman.ignore +4 -4
  32. data/config/initializers/rabl.rb +0 -40
  33. data/config/locales/en/api_client.yml +22 -0
  34. data/config/locales/en/async.yml +34 -0
  35. data/config/locales/en/dashboard.yml +25 -0
  36. data/config/locales/en/invites.yml +37 -0
  37. data/config/locales/en/js.yml +40 -0
  38. data/config/locales/en/kanban.yml +28 -0
  39. data/config/locales/en/plutonium.yml +26 -0
  40. data/config/locales/en/positioning.yml +14 -0
  41. data/config/locales/en/profile.yml +29 -0
  42. data/config/locales/en/resource.yml +60 -0
  43. data/config/locales/en/ui.yml +93 -0
  44. data/config/locales/en/wizard.yml +46 -0
  45. data/docs/.vitepress/config.ts +34 -1
  46. data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
  47. data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
  48. data/docs/.vitepress/theme/custom.css +34 -377
  49. data/docs/blog/introducing-plutonium-dashboards.md +175 -0
  50. data/docs/blog/introducing-plutonium-i18n.md +91 -0
  51. data/docs/blog/introducing-plutonium.md +4 -5
  52. data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
  53. data/docs/guides/dashboards.md +300 -0
  54. data/docs/guides/index.md +1 -0
  55. data/docs/guides/kanban.md +2 -2
  56. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  57. data/docs/public/images/blog/dashboards-loading.png +0 -0
  58. data/docs/public/images/blog/dashboards-overview.png +0 -0
  59. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
  60. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  61. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  62. data/docs/public/images/guides/dashboard-loading.png +0 -0
  63. data/docs/public/images/guides/dashboard-overview.png +0 -0
  64. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  65. data/docs/reference/app/generators.md +2 -2
  66. data/docs/reference/app/portals.md +2 -0
  67. data/docs/reference/configuration.md +84 -27
  68. data/docs/reference/dashboard/dsl.md +123 -0
  69. data/docs/reference/dashboard/index.md +35 -0
  70. data/docs/reference/dashboard/registration.md +76 -0
  71. data/docs/reference/i18n.md +239 -0
  72. data/docs/reference/index.md +10 -1
  73. data/docs/reference/kanban/dsl.md +1 -1
  74. data/docs/reference/kanban/positioning.md +2 -2
  75. data/docs/reference/resource/actions.md +1 -1
  76. data/docs/reference/resource/definition.md +96 -18
  77. data/docs/reference/resource/export.md +5 -1
  78. data/docs/reference/resource/index.md +2 -0
  79. data/docs/reference/{positioning.md → resource/positioning.md} +2 -1
  80. data/docs/reference/resource/query.md +2 -0
  81. data/docs/reference/ui/assets.md +8 -0
  82. data/docs/reference/ui/displays.md +5 -8
  83. data/docs/reference/ui/index.md +1 -1
  84. data/docs/reference/wizard/dsl.md +2 -2
  85. data/esbuild.config.js +5 -2
  86. data/gemfiles/rails_7.gemfile.lock +2 -2
  87. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  88. data/lib/active_model/validations/array_validator.rb +1 -1
  89. data/lib/active_model/validations/attached_validator.rb +1 -1
  90. data/lib/active_model/validations/url_validator.rb +2 -2
  91. data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
  92. data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
  93. data/lib/generators/pu/core/update/update_generator.rb +3 -2
  94. data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
  95. data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
  96. data/lib/generators/pu/docker/install/install_generator.rb +58 -0
  97. data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
  98. data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
  99. data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
  100. data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
  101. data/lib/generators/pu/invites/invitable_generator.rb +18 -1
  102. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
  103. data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
  104. data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
  105. data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
  106. data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
  107. data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
  108. data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
  109. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
  110. data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
  111. data/lib/plutonium/action/base.rb +27 -3
  112. data/lib/plutonium/action/interactive.rb +17 -4
  113. data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
  114. data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
  115. data/lib/plutonium/auth/sequel_adapter.rb +1 -1
  116. data/lib/plutonium/configuration.rb +5 -0
  117. data/lib/plutonium/core/controller.rb +25 -1
  118. data/lib/plutonium/core/controllers/authorizable.rb +1 -1
  119. data/lib/plutonium/dashboard/base.rb +114 -0
  120. data/lib/plutonium/dashboard/card.rb +200 -0
  121. data/lib/plutonium/dashboard/controller.rb +101 -0
  122. data/lib/plutonium/dashboard/dsl.rb +104 -0
  123. data/lib/plutonium/dashboard/register.rb +40 -0
  124. data/lib/plutonium/dashboard/route_resolution.rb +30 -0
  125. data/lib/plutonium/dashboard.rb +42 -0
  126. data/lib/plutonium/definition/actions.rb +6 -1
  127. data/lib/plutonium/definition/base.rb +41 -3
  128. data/lib/plutonium/definition/index_views.rb +1 -1
  129. data/lib/plutonium/definition/positioning.rb +1 -1
  130. data/lib/plutonium/definition/presentable.rb +2 -2
  131. data/lib/plutonium/helpers/assets_helper.rb +15 -2
  132. data/lib/plutonium/helpers/display_helper.rb +10 -2
  133. data/lib/plutonium/interaction/async/context.rb +6 -8
  134. data/lib/plutonium/interaction/async/executor.rb +6 -7
  135. data/lib/plutonium/interaction/async/run.rb +2 -2
  136. data/lib/plutonium/interaction/async/run_definition.rb +1 -1
  137. data/lib/plutonium/interaction/base.rb +2 -0
  138. data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
  139. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
  140. data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
  141. data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
  142. data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
  143. data/lib/plutonium/invites/controller.rb +11 -11
  144. data/lib/plutonium/kanban/column.rb +13 -3
  145. data/lib/plutonium/kanban/dsl.rb +7 -4
  146. data/lib/plutonium/models/has_cents.rb +1 -1
  147. data/lib/plutonium/profile/security_section.rb +10 -18
  148. data/lib/plutonium/query/filter.rb +10 -1
  149. data/lib/plutonium/query/filters/association.rb +27 -2
  150. data/lib/plutonium/query/filters/boolean.rb +4 -4
  151. data/lib/plutonium/query/filters/date.rb +4 -17
  152. data/lib/plutonium/query/filters/date_range.rb +2 -2
  153. data/lib/plutonium/query/filters/select.rb +10 -1
  154. data/lib/plutonium/query/filters/text.rb +9 -12
  155. data/lib/plutonium/railtie.rb +17 -0
  156. data/lib/plutonium/resource/controller.rb +10 -5
  157. data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
  158. data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
  159. data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
  160. data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
  161. data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
  162. data/lib/plutonium/resource/query_object.rb +30 -3
  163. data/lib/plutonium/routing/dashboard_registration.rb +137 -0
  164. data/lib/plutonium/routing/resource_registration.rb +5 -0
  165. data/lib/plutonium/routing/route_set_extensions.rb +2 -1
  166. data/lib/plutonium/translation.rb +227 -0
  167. data/lib/plutonium/ui/actions_dropdown.rb +2 -2
  168. data/lib/plutonium/ui/breadcrumbs.rb +3 -3
  169. data/lib/plutonium/ui/color_mode_selector.rb +1 -1
  170. data/lib/plutonium/ui/component/behaviour.rb +46 -0
  171. data/lib/plutonium/ui/component/methods.rb +4 -0
  172. data/lib/plutonium/ui/dashboard/board.rb +80 -0
  173. data/lib/plutonium/ui/dashboard/card.rb +127 -0
  174. data/lib/plutonium/ui/dashboard/chart.rb +43 -0
  175. data/lib/plutonium/ui/dashboard/custom.rb +32 -0
  176. data/lib/plutonium/ui/dashboard/frame.rb +25 -0
  177. data/lib/plutonium/ui/dashboard/metric.rb +137 -0
  178. data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
  179. data/lib/plutonium/ui/display/components/attachment.rb +1 -1
  180. data/lib/plutonium/ui/display/components/badge.rb +7 -1
  181. data/lib/plutonium/ui/display/components/boolean.rb +2 -2
  182. data/lib/plutonium/ui/display/resource.rb +23 -16
  183. data/lib/plutonium/ui/export_button.rb +3 -3
  184. data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
  185. data/lib/plutonium/ui/form/components/password.rb +2 -2
  186. data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
  187. data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
  188. data/lib/plutonium/ui/form/components/uppy.rb +11 -3
  189. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
  190. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
  191. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
  192. data/lib/plutonium/ui/form/query.rb +8 -8
  193. data/lib/plutonium/ui/form/resource.rb +54 -26
  194. data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
  195. data/lib/plutonium/ui/grid/card.rb +3 -3
  196. data/lib/plutonium/ui/grid/resource.rb +3 -3
  197. data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
  198. data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
  199. data/lib/plutonium/ui/kanban/column.rb +6 -6
  200. data/lib/plutonium/ui/layout/base.rb +19 -1
  201. data/lib/plutonium/ui/layout/header.rb +1 -1
  202. data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
  203. data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
  204. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  205. data/lib/plutonium/ui/layout/topbar.rb +2 -2
  206. data/lib/plutonium/ui/modal/base.rb +4 -4
  207. data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
  208. data/lib/plutonium/ui/nav_user.rb +2 -2
  209. data/lib/plutonium/ui/page/base.rb +10 -2
  210. data/lib/plutonium/ui/page/dashboard.rb +54 -0
  211. data/lib/plutonium/ui/page/edit.rb +3 -3
  212. data/lib/plutonium/ui/page/interactive_action.rb +2 -2
  213. data/lib/plutonium/ui/page/new.rb +3 -3
  214. data/lib/plutonium/ui/page/show.rb +2 -2
  215. data/lib/plutonium/ui/page/wizard.rb +22 -16
  216. data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
  217. data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
  218. data/lib/plutonium/ui/skeleton_table.rb +1 -1
  219. data/lib/plutonium/ui/table/base.rb +2 -2
  220. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  221. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
  222. data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
  223. data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
  224. data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
  225. data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
  226. data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
  227. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  228. data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
  229. data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
  230. data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
  231. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  232. data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
  233. data/lib/plutonium/ui/table/resource.rb +53 -17
  234. data/lib/plutonium/ui/wizard/review.rb +7 -7
  235. data/lib/plutonium/ui/wizard/stepper.rb +1 -1
  236. data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
  237. data/lib/plutonium/version.rb +1 -1
  238. data/lib/plutonium/wizard/base.rb +2 -0
  239. data/lib/plutonium/wizard/driving.rb +8 -2
  240. data/lib/plutonium/wizard/dsl.rb +4 -2
  241. data/lib/plutonium/wizard/field_importer.rb +1 -8
  242. data/lib/plutonium/wizard/review_step.rb +8 -1
  243. data/lib/plutonium/wizard/step.rb +14 -3
  244. data/package.json +4 -2
  245. data/plutonium.gemspec +4 -1
  246. data/postcss-gem-import.cjs +34 -0
  247. data/postcss-gem-import.js +3 -28
  248. data/src/css/components.css +91 -0
  249. data/src/css/slim_select.css +0 -38
  250. data/src/css/tokens.css +24 -0
  251. data/src/js/controllers/attachment_input_controller.js +15 -6
  252. data/src/js/controllers/chart_controller.js +162 -0
  253. data/src/js/controllers/clipboard_controller.js +3 -2
  254. data/src/js/controllers/dirty_form_guard_controller.js +4 -3
  255. data/src/js/controllers/easymde_controller.js +3 -0
  256. data/src/js/controllers/filter_panel_controller.js +15 -6
  257. data/src/js/controllers/flatpickr_controller.js +8 -0
  258. data/src/js/controllers/frame_refresh_controller.js +59 -0
  259. data/src/js/controllers/intl_tel_input_controller.js +5 -0
  260. data/src/js/controllers/register_controllers.js +4 -0
  261. data/src/js/controllers/slim_select_controller.js +79 -176
  262. data/src/js/core.js +9 -1
  263. data/src/js/i18n.js +69 -0
  264. data/src/js/plutonium-charts.js +15 -0
  265. data/src/js/turbo/turbo_confirm.js +5 -3
  266. metadata +64 -16
  267. data/.yarnrc.yml +0 -8
  268. data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
  269. data/lib/generators/pu/field/input/input_generator.rb +0 -32
  270. data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
  271. data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
  272. data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
  273. data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
  274. data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
  275. data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
  276. data/public/plutonium-assets/application.js +0 -31419
  277. data/public/plutonium-assets/plutonium.ico +0 -0
  278. data/yarn.lock +0 -6858
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
+ import { t } from "../i18n.js";
2
3
 
3
4
  // Connects to data-controller="slim-select"
4
5
  //
@@ -33,28 +34,33 @@ export default class extends Controller {
33
34
 
34
35
  #setupSlimSelect() {
35
36
  const settings = {};
36
- const modal = document.querySelector('[data-controller="remote-modal"]');
37
37
 
38
- if (modal) {
39
- // Create a dedicated container div right after the select element
40
- this.dropdownContainer = document.createElement("div");
41
- this.dropdownContainer.className = "ss-dropdown-container";
42
-
43
- // Make the select wrapper position relative to contain the absolute dropdown
44
- const selectWrapper = this.element.parentNode;
45
- const originalPosition = getComputedStyle(selectWrapper).position;
46
- if (originalPosition === "static") {
47
- selectWrapper.style.position = "relative";
48
- this.modifiedSelectWrapper = selectWrapper;
49
- }
50
-
51
- // Insert the container right after the select element
52
- this.element.parentNode.insertBefore(
53
- this.dropdownContainer,
54
- this.element.nextSibling
55
- );
38
+ // Optional locale passthrough: plutonium.js.libraries.slim_select may
39
+ // define placeholderText, searchText, searchPlaceholder and searchingText.
40
+ const strings = t("plutonium.js.libraries.slim_select");
41
+ if (strings && typeof strings === "object") {
42
+ const { placeholderText, searchText, searchPlaceholder, searchingText } = strings;
43
+ Object.assign(settings, { placeholderText, searchText, searchPlaceholder, searchingText });
44
+ Object.keys(settings).forEach((k) => settings[k] === undefined && delete settings[k]);
45
+ }
56
46
 
57
- settings.contentLocation = this.dropdownContainer;
47
+ this.modal = this.element.closest('[data-controller="remote-modal"]');
48
+
49
+ if (this.modal) {
50
+ // Inside a <dialog> opened via showModal(), the dialog lives in the
51
+ // browser's top layer: anything rendered outside it (e.g. on
52
+ // document.body) paints BEHIND it regardless of z-index, and the
53
+ // dialog is transform-animated (slide/scale), so position:fixed
54
+ // descendants are rebased to the dialog's box rather than the
55
+ // viewport. Rendering the dropdown into the select's own container
56
+ // (SlimSelect's other default) instead leaves it clipped by the
57
+ // form section's overflow.
58
+ //
59
+ // So attach the dropdown to the dialog itself and position it
60
+ // absolutely relative to the dialog's box (see #applyModalPositioning).
61
+ // It then escapes every section's overflow while staying inside the
62
+ // top layer, on top of the form. Mirrors the flatpickr controller.
63
+ settings.contentLocation = this.modal;
58
64
  settings.contentPosition = "absolute";
59
65
  settings.openPosition = "auto";
60
66
  }
@@ -73,125 +79,58 @@ export default class extends Controller {
73
79
  events: events,
74
80
  });
75
81
 
76
- // Add event listeners for better positioning
77
- this.handleDropdownPosition();
78
-
79
- // Bind event handlers for proper cleanup
80
- this.boundHandleDropdownOpen = this.handleDropdownOpen.bind(this);
81
- this.boundHandleDropdownClose = this.handleDropdownClose.bind(this);
82
-
83
- // Add event listeners to properly handle dropdown visibility
84
- this.element.addEventListener("ss:open", this.boundHandleDropdownOpen);
85
- this.element.addEventListener("ss:close", this.boundHandleDropdownClose);
86
-
87
- // Add mutation observer to track aria-expanded attribute
88
- this.setupAriaObserver();
89
- }
90
-
91
- handleDropdownPosition() {
92
- if (this.dropdownContainer) {
93
- // Reposition dropdown when window resizes or scrolls
94
- const repositionDropdown = () => {
95
- const selectRect = this.element.getBoundingClientRect();
96
-
97
- // Calculate if there's enough space below
98
- const spaceBelow = window.innerHeight - selectRect.bottom;
99
- const spaceAbove = selectRect.top;
100
-
101
- if (spaceBelow < 200 && spaceAbove > spaceBelow) {
102
- // Position above if not enough space below
103
- this.dropdownContainer.style.top = "auto";
104
- this.dropdownContainer.style.bottom = "100%";
105
- this.dropdownContainer.style.borderRadius = "0.375rem 0.375rem 0 0";
106
- } else {
107
- // Position below (default)
108
- this.dropdownContainer.style.bottom = "auto";
109
- this.dropdownContainer.style.borderRadius = "0 0 0.375rem 0.375rem";
110
- }
111
- };
112
-
113
- // Initial positioning
114
- setTimeout(repositionDropdown, 0);
115
-
116
- // Reposition on events
117
- window.addEventListener("resize", repositionDropdown);
118
- window.addEventListener("scroll", repositionDropdown);
119
-
120
- // Store references for cleanup
121
- this.repositionDropdown = repositionDropdown;
122
- }
123
- }
124
-
125
- handleDropdownOpen() {
126
- if (this.dropdownContainer) {
127
- // When dropdown opens, ensure our container is properly sized
128
- this.dropdownContainer.style.height = "auto";
129
- this.dropdownContainer.style.overflow = "visible";
130
-
131
- // Add open class for better CSS targeting
132
- this.dropdownContainer.classList.add("ss-active");
133
-
134
- // Ensure this dropdown appears above others
135
- const allContainers = document.querySelectorAll(".ss-dropdown-container");
136
- allContainers.forEach((container) => {
137
- if (container !== this.dropdownContainer) {
138
- container.style.zIndex = "9999";
139
- }
140
- });
141
- this.dropdownContainer.style.zIndex = "10000";
82
+ if (this.modal) {
83
+ this.#applyModalPositioning();
142
84
  }
143
85
  }
144
86
 
145
- handleDropdownClose() {
146
- if (this.dropdownContainer) {
147
- // Remove active class
148
- this.dropdownContainer.classList.remove("ss-active");
149
- }
150
- }
151
-
152
- setupAriaObserver() {
153
- // Track aria-expanded attribute on the select element or its wrapper
154
- if (this.element) {
155
- this.ariaObserver = new MutationObserver((mutations) => {
156
- mutations.forEach((mutation) => {
157
- if (mutation.attributeName === "aria-expanded") {
158
- const expanded =
159
- mutation.target.getAttribute("aria-expanded") === "true";
160
- if (expanded) {
161
- this.handleDropdownOpen();
162
- } else {
163
- this.handleDropdownClose();
164
- }
165
- }
166
- });
167
- });
168
-
169
- // Look for the actual element that gets the aria-expanded attribute
170
- const possibleTargets = [
171
- this.element,
172
- this.element.parentNode.querySelector(".ss-main"),
173
- this.element.parentNode.querySelector("[aria-expanded]"),
174
- ];
175
-
176
- const target = possibleTargets.find(
177
- (el) => el && el.hasAttribute && el.hasAttribute("aria-expanded")
178
- );
179
-
180
- if (target) {
181
- this.ariaObserver.observe(target, {
182
- attributes: true,
183
- attributeFilter: ["aria-expanded"],
184
- });
185
-
186
- // Check initial state
187
- const expanded = target.getAttribute("aria-expanded") === "true";
188
- if (expanded) {
189
- this.handleDropdownOpen();
190
- } else {
191
- this.handleDropdownClose();
192
- }
87
+ // Re-base SlimSelect's dropdown positioning onto the modal's coordinate
88
+ // system. SlimSelect computes document coordinates (rect + window scroll),
89
+ // which are wrong for content parented to a transform-animated <dialog>:
90
+ // an absolutely-positioned child is placed relative to the dialog's box,
91
+ // not the document. We override the two leaf positioners so SlimSelect
92
+ // keeps deciding up-vs-down (and its own open/scroll/resize triggers keep
93
+ // firing), but the actual offsets are measured against the dialog. A
94
+ // capture-phase scroll listener covers scrolls of the dialog's own
95
+ // content, which never reach SlimSelect's window-level scroll listener.
96
+ #applyModalPositioning() {
97
+ const render = this.slimSelect.render;
98
+ const openAbove = render.classes.openAbove;
99
+ const openBelow = render.classes.openBelow;
100
+
101
+ const place = (above) => {
102
+ const mainEl = render.main.main;
103
+ const content = render.content.main;
104
+ const mainHeight = mainEl.offsetHeight;
105
+ const contentHeight = content.offsetHeight;
106
+
107
+ mainEl.classList.remove(openAbove, openBelow);
108
+ content.classList.remove(openAbove, openBelow);
109
+ mainEl.classList.add(above ? openAbove : openBelow);
110
+ content.classList.add(above ? openAbove : openBelow);
111
+
112
+ const inputRect = mainEl.getBoundingClientRect();
113
+ const modalRect = this.modal.getBoundingClientRect();
114
+
115
+ content.style.margin = above
116
+ ? "-" + (mainHeight + contentHeight - 1) + "px 0px 0px 0px"
117
+ : "-1px 0px 0px 0px";
118
+ content.style.top =
119
+ inputRect.top + inputRect.height - modalRect.top + "px";
120
+ content.style.left = inputRect.left - modalRect.left + "px";
121
+ content.style.width = inputRect.width + "px";
122
+ };
123
+
124
+ render.moveContentAbove = () => place(true);
125
+ render.moveContentBelow = () => place(false);
126
+
127
+ this.boundModalReposition = () => {
128
+ if (!this.slimSelect) return;
129
+ if (this.slimSelect.settings.isOpen || this.slimSelect.settings.isFullOpen) {
130
+ this.slimSelect.render.moveContent();
193
131
  }
194
- }
132
+ };
133
+ document.addEventListener("scroll", this.boundModalReposition, true);
195
134
  }
196
135
 
197
136
  disconnect() {
@@ -224,7 +163,7 @@ export default class extends Controller {
224
163
  headers: { Accept: "application/json" },
225
164
  signal: signal,
226
165
  });
227
- if (!res.ok) return "Search failed";
166
+ if (!res.ok) return t("plutonium.js.slim_select.search_failed");
228
167
  const json = await res.json();
229
168
  const results = Array.isArray(json.results) ? json.results : [];
230
169
  return results.map((row) => ({
@@ -234,7 +173,7 @@ export default class extends Controller {
234
173
  } catch (e) {
235
174
  if (e.name === "AbortError") return [];
236
175
  console.warn("[slim-select] typeahead error", e);
237
- return "Search failed";
176
+ return t("plutonium.js.slim_select.search_failed");
238
177
  }
239
178
  }
240
179
 
@@ -249,50 +188,14 @@ export default class extends Controller {
249
188
  }
250
189
 
251
190
  #cleanupSlimSelect() {
252
- // Clean up event listeners
253
- if (this.element) {
254
- if (this.boundHandleDropdownOpen) {
255
- this.element.removeEventListener(
256
- "ss:open",
257
- this.boundHandleDropdownOpen
258
- );
259
- }
260
- if (this.boundHandleDropdownClose) {
261
- this.element.removeEventListener(
262
- "ss:close",
263
- this.boundHandleDropdownClose
264
- );
265
- }
266
- }
267
-
268
- // Disconnect observer
269
- if (this.ariaObserver) {
270
- this.ariaObserver.disconnect();
271
- this.ariaObserver = null;
191
+ if (this.boundModalReposition) {
192
+ document.removeEventListener("scroll", this.boundModalReposition, true);
193
+ this.boundModalReposition = null;
272
194
  }
273
195
 
274
196
  if (this.slimSelect) {
275
197
  this.slimSelect.destroy();
276
198
  this.slimSelect = null;
277
199
  }
278
-
279
- // Clean up event listeners
280
- if (this.repositionDropdown) {
281
- window.removeEventListener("resize", this.repositionDropdown);
282
- window.removeEventListener("scroll", this.repositionDropdown);
283
- this.repositionDropdown = null;
284
- }
285
-
286
- // Clean up the dropdown container if it exists
287
- if (this.dropdownContainer && this.dropdownContainer.parentNode) {
288
- this.dropdownContainer.parentNode.removeChild(this.dropdownContainer);
289
- this.dropdownContainer = null;
290
- }
291
-
292
- // Restore original positioning if we modified it
293
- if (this.modifiedSelectWrapper) {
294
- this.modifiedSelectWrapper.style.position = "";
295
- this.modifiedSelectWrapper = null;
296
- }
297
200
  }
298
201
  }
data/src/js/core.js CHANGED
@@ -1,4 +1,12 @@
1
1
  import registerControllers from "./controllers/register_controllers.js"
2
- export { registerControllers }
2
+ import { t } from "./i18n.js"
3
+ export { registerControllers, t }
4
+
5
+ // Expose the translator so host apps (and inline scripts) can reuse the
6
+ // `plutonium.js.*` strings without importing the bundle.
7
+ if (typeof window !== "undefined") {
8
+ window.Plutonium = window.Plutonium || {}
9
+ window.Plutonium.t = t
10
+ }
3
11
 
4
12
  import "./turbo"
data/src/js/i18n.js ADDED
@@ -0,0 +1,69 @@
1
+ // Translations for Plutonium's bundled JavaScript.
2
+ //
3
+ // Rails serialises the `plutonium.js` subtree of the current locale into
4
+ // `<meta name="pu-i18n" content="...">` in the layout head (see
5
+ // Plutonium::UI::Layout::Base#render_i18n). This module reads that blob
6
+ // lazily, so host apps override any string by defining the key in their own
7
+ // config/locales, exactly as they do for Plutonium's Ruby-side strings.
8
+ //
9
+ // t("plutonium.js.clipboard.copied") // "Copied!"
10
+ // t("plutonium.js.attachment_input.delete_all", {count: 3}) // "Delete 3"
11
+ // t("plutonium.js.libraries.slim_select") // {placeholderText: ...} or the key
12
+ //
13
+ // Lookup rules mirror I18n: `%{name}` placeholders are interpolated from
14
+ // `params`; a `count` param picks the `one` / `other` subkey when the value
15
+ // is a plural object; a missing key falls back to the key itself.
16
+
17
+ const PREFIX = "plutonium.js."
18
+ const BLOB_SELECTOR = 'meta[name="pu-i18n"]'
19
+
20
+ let dictionary = null
21
+
22
+ function load() {
23
+ if (typeof document === "undefined") return {}
24
+ const el = document.querySelector(BLOB_SELECTOR)
25
+ if (!el) return {}
26
+ try {
27
+ return JSON.parse(el.content) || {}
28
+ } catch (e) {
29
+ console.warn("[plutonium] could not parse the pu-i18n locale blob", e)
30
+ return {}
31
+ }
32
+ }
33
+
34
+ // Raw value for a key (string, plural object, library object) or undefined.
35
+ export function lookup(key) {
36
+ if (dictionary === null) dictionary = load()
37
+ const path = key.startsWith(PREFIX) ? key.slice(PREFIX.length) : key
38
+ return path.split(".").reduce(
39
+ (node, part) => (node != null && typeof node === "object" ? node[part] : undefined),
40
+ dictionary
41
+ )
42
+ }
43
+
44
+ function interpolate(text, params) {
45
+ return text.replace(/%\{(\w+)\}/g, (match, name) =>
46
+ Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
47
+ )
48
+ }
49
+
50
+ export function t(key, params = {}) {
51
+ let value = lookup(key)
52
+
53
+ if (value != null && typeof value === "object" && "count" in params) {
54
+ value = params.count === 1 ? value.one : value.other
55
+ }
56
+
57
+ if (typeof value === "string") return interpolate(value, params)
58
+ return value === undefined ? key : value
59
+ }
60
+
61
+ // Turbo Drive replaces the head's <meta> elements on navigation, so the blob
62
+ // changes when the locale does; drop the cache after each render.
63
+ if (typeof document !== "undefined") {
64
+ const reset = () => { dictionary = null }
65
+ document.addEventListener("turbo:load", reset)
66
+ document.addEventListener("turbo:render", reset)
67
+ }
68
+
69
+ export default t
@@ -0,0 +1,15 @@
1
+ // The on-demand charts bundle: Chart.js wired into Chartkick.
2
+ //
3
+ // Built as its own entry point (app/assets/plutonium-charts.js) and NOT
4
+ // imported by plutonium.js, so pages without a chart never download it. The
5
+ // `chart` Stimulus controller injects this script the first time a chart card
6
+ // connects (its URL rides the card's data-chart-script-value) and then draws
7
+ // through `window.Chartkick`.
8
+ import Chartkick from "chartkick"
9
+ import "chartkick/chart.js"
10
+
11
+ if (typeof window !== "undefined") {
12
+ window.Chartkick = Chartkick
13
+ }
14
+
15
+ export default Chartkick
@@ -1,6 +1,8 @@
1
1
  // Themed replacement for Turbo's default window.confirm. The dialog is
2
2
  // built lazily and reused so per-call cost is just a textContent swap.
3
3
 
4
+ import { t } from "../i18n.js";
5
+
4
6
  let dialog;
5
7
  let messageEl;
6
8
  let confirmButton;
@@ -55,12 +57,12 @@ function ensureDialog() {
55
57
  cancelButton = document.createElement("button");
56
58
  cancelButton.type = "button";
57
59
  cancelButton.className = "pu-btn pu-btn-md pu-btn-outline";
58
- cancelButton.textContent = "Cancel";
60
+ cancelButton.textContent = t("plutonium.js.turbo_confirm.cancel");
59
61
 
60
62
  confirmButton = document.createElement("button");
61
63
  confirmButton.type = "button";
62
64
  confirmButton.className = "pu-btn pu-btn-md pu-btn-primary";
63
- confirmButton.textContent = "Confirm";
65
+ confirmButton.textContent = t("plutonium.js.turbo_confirm.confirm");
64
66
 
65
67
  footer.appendChild(cancelButton);
66
68
  footer.appendChild(confirmButton);
@@ -79,7 +81,7 @@ async function animateClose() {
79
81
 
80
82
  function themedConfirm(message) {
81
83
  ensureDialog();
82
- messageEl.textContent = message || "Are you sure?";
84
+ messageEl.textContent = message || t("plutonium.js.are_you_sure");
83
85
 
84
86
  return new Promise((resolve) => {
85
87
  let settled = false;