rails_admin_next 1.0.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 (184) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +46 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +317 -0
  5. data/Rakefile +25 -0
  6. data/app/assets/javascripts/rails_admin/custom/ui.js +1 -0
  7. data/app/assets/stylesheets/rails_admin/framework.css +2375 -0
  8. data/app/assets/stylesheets/rails_admin/skin.css +472 -0
  9. data/app/assets/stylesheets/rails_admin/tokens.css +281 -0
  10. data/app/assets/stylesheets/rails_admin.css +47 -0
  11. data/app/controllers/rails_admin_next/application_controller.rb +98 -0
  12. data/app/controllers/rails_admin_next/main_controller.rb +185 -0
  13. data/app/helpers/rails_admin_next/application_helper.rb +254 -0
  14. data/app/helpers/rails_admin_next/form_builder.rb +173 -0
  15. data/app/helpers/rails_admin_next/main_helper.rb +63 -0
  16. data/app/views/layouts/rails_admin_next/_head.html.erb +29 -0
  17. data/app/views/layouts/rails_admin_next/_navigation.html.erb +18 -0
  18. data/app/views/layouts/rails_admin_next/_secondary_navigation.html.erb +27 -0
  19. data/app/views/layouts/rails_admin_next/_sidebar_navigation.html.erb +6 -0
  20. data/app/views/layouts/rails_admin_next/application.html.erb +42 -0
  21. data/app/views/layouts/rails_admin_next/content.html.erb +22 -0
  22. data/app/views/layouts/rails_admin_next/modal.js.erb +7 -0
  23. data/app/views/rails_admin_next/main/_dashboard_history.html.erb +46 -0
  24. data/app/views/rails_admin_next/main/_delete_notice.html.erb +36 -0
  25. data/app/views/rails_admin_next/main/_form_action_text.html.erb +2 -0
  26. data/app/views/rails_admin_next/main/_form_boolean.html.erb +15 -0
  27. data/app/views/rails_admin_next/main/_form_colorpicker.html.erb +6 -0
  28. data/app/views/rails_admin_next/main/_form_datetime.html.erb +14 -0
  29. data/app/views/rails_admin_next/main/_form_enumeration.html.erb +26 -0
  30. data/app/views/rails_admin_next/main/_form_field.html.erb +2 -0
  31. data/app/views/rails_admin_next/main/_form_file_upload.html.erb +18 -0
  32. data/app/views/rails_admin_next/main/_form_filtering_multiselect.html.erb +20 -0
  33. data/app/views/rails_admin_next/main/_form_filtering_select.html.erb +22 -0
  34. data/app/views/rails_admin_next/main/_form_multiple_file_upload.html.erb +21 -0
  35. data/app/views/rails_admin_next/main/_form_nested_many.html.erb +20 -0
  36. data/app/views/rails_admin_next/main/_form_nested_one.html.erb +19 -0
  37. data/app/views/rails_admin_next/main/_form_polymorphic_association.html.erb +24 -0
  38. data/app/views/rails_admin_next/main/_form_text.html.erb +2 -0
  39. data/app/views/rails_admin_next/main/_pagination.html.erb +54 -0
  40. data/app/views/rails_admin_next/main/_submit_buttons.html.erb +26 -0
  41. data/app/views/rails_admin_next/main/bulk_delete.html.erb +19 -0
  42. data/app/views/rails_admin_next/main/dashboard.html.erb +64 -0
  43. data/app/views/rails_admin_next/main/delete.html.erb +21 -0
  44. data/app/views/rails_admin_next/main/edit.html.erb +3 -0
  45. data/app/views/rails_admin_next/main/export.html.erb +149 -0
  46. data/app/views/rails_admin_next/main/history.html.erb +80 -0
  47. data/app/views/rails_admin_next/main/index.html.erb +186 -0
  48. data/app/views/rails_admin_next/main/new.html.erb +3 -0
  49. data/app/views/rails_admin_next/main/show.html.erb +30 -0
  50. data/config/brakeman.ignore +145 -0
  51. data/config/importmap.rails_admin.rb +29 -0
  52. data/config/initializers/active_record_extensions.rb +21 -0
  53. data/config/locales/rails_admin_next.en.yml +158 -0
  54. data/config/routes.rb +14 -0
  55. data/lib/generators/rails_admin_next/install_generator.rb +39 -0
  56. data/lib/generators/rails_admin_next/templates/initializer.erb +40 -0
  57. data/lib/generators/rails_admin_next/utils.rb +21 -0
  58. data/lib/rails_admin_next/abstract_model.rb +266 -0
  59. data/lib/rails_admin_next/adapters/active_record/association.rb +130 -0
  60. data/lib/rails_admin_next/adapters/active_record/object_extension.rb +13 -0
  61. data/lib/rails_admin_next/adapters/active_record/property.rb +58 -0
  62. data/lib/rails_admin_next/adapters/active_record.rb +371 -0
  63. data/lib/rails_admin_next/config/actions/base.rb +160 -0
  64. data/lib/rails_admin_next/config/actions/bulk_delete.rb +67 -0
  65. data/lib/rails_admin_next/config/actions/dashboard.rb +62 -0
  66. data/lib/rails_admin_next/config/actions/delete.rb +58 -0
  67. data/lib/rails_admin_next/config/actions/edit.rb +56 -0
  68. data/lib/rails_admin_next/config/actions/export.rb +47 -0
  69. data/lib/rails_admin_next/config/actions/history_index.rb +40 -0
  70. data/lib/rails_admin_next/config/actions/history_show.rb +40 -0
  71. data/lib/rails_admin_next/config/actions/index.rb +108 -0
  72. data/lib/rails_admin_next/config/actions/new.rb +69 -0
  73. data/lib/rails_admin_next/config/actions/show.rb +36 -0
  74. data/lib/rails_admin_next/config/actions/show_in_app.rb +37 -0
  75. data/lib/rails_admin_next/config/actions.rb +122 -0
  76. data/lib/rails_admin_next/config/configurable.rb +107 -0
  77. data/lib/rails_admin_next/config/const_load_suppressor.rb +78 -0
  78. data/lib/rails_admin_next/config/fields/association.rb +169 -0
  79. data/lib/rails_admin_next/config/fields/base.rb +391 -0
  80. data/lib/rails_admin_next/config/fields/collection_association.rb +90 -0
  81. data/lib/rails_admin_next/config/fields/factories/action_text.rb +14 -0
  82. data/lib/rails_admin_next/config/fields/factories/active_storage.rb +34 -0
  83. data/lib/rails_admin_next/config/fields/factories/association.rb +32 -0
  84. data/lib/rails_admin_next/config/fields/factories/encrypted_password.rb +28 -0
  85. data/lib/rails_admin_next/config/fields/factories/enum.rb +23 -0
  86. data/lib/rails_admin_next/config/fields/factories/password.rb +19 -0
  87. data/lib/rails_admin_next/config/fields/group.rb +77 -0
  88. data/lib/rails_admin_next/config/fields/singular_association.rb +62 -0
  89. data/lib/rails_admin_next/config/fields/types/action_text.rb +20 -0
  90. data/lib/rails_admin_next/config/fields/types/active_record_enum.rb +62 -0
  91. data/lib/rails_admin_next/config/fields/types/active_storage.rb +70 -0
  92. data/lib/rails_admin_next/config/fields/types/all.rb +32 -0
  93. data/lib/rails_admin_next/config/fields/types/belongs_to_association.rb +48 -0
  94. data/lib/rails_admin_next/config/fields/types/boolean.rb +70 -0
  95. data/lib/rails_admin_next/config/fields/types/citext.rb +15 -0
  96. data/lib/rails_admin_next/config/fields/types/color.rb +43 -0
  97. data/lib/rails_admin_next/config/fields/types/date.rb +38 -0
  98. data/lib/rails_admin_next/config/fields/types/datetime.rb +79 -0
  99. data/lib/rails_admin_next/config/fields/types/decimal.rb +23 -0
  100. data/lib/rails_admin_next/config/fields/types/enum.rb +52 -0
  101. data/lib/rails_admin_next/config/fields/types/file_upload.rb +87 -0
  102. data/lib/rails_admin_next/config/fields/types/float.rb +23 -0
  103. data/lib/rails_admin_next/config/fields/types/has_and_belongs_to_many_association.rb +16 -0
  104. data/lib/rails_admin_next/config/fields/types/has_many_association.rb +16 -0
  105. data/lib/rails_admin_next/config/fields/types/has_one_association.rb +35 -0
  106. data/lib/rails_admin_next/config/fields/types/hidden.rb +31 -0
  107. data/lib/rails_admin_next/config/fields/types/inet.rb +15 -0
  108. data/lib/rails_admin_next/config/fields/types/integer.rb +20 -0
  109. data/lib/rails_admin_next/config/fields/types/json.rb +37 -0
  110. data/lib/rails_admin_next/config/fields/types/multiple_active_storage.rb +82 -0
  111. data/lib/rails_admin_next/config/fields/types/multiple_file_upload.rb +134 -0
  112. data/lib/rails_admin_next/config/fields/types/numeric.rb +24 -0
  113. data/lib/rails_admin_next/config/fields/types/password.rb +45 -0
  114. data/lib/rails_admin_next/config/fields/types/polymorphic_association.rb +118 -0
  115. data/lib/rails_admin_next/config/fields/types/serialized.rb +28 -0
  116. data/lib/rails_admin_next/config/fields/types/string.rb +50 -0
  117. data/lib/rails_admin_next/config/fields/types/string_like.rb +25 -0
  118. data/lib/rails_admin_next/config/fields/types/text.rb +28 -0
  119. data/lib/rails_admin_next/config/fields/types/time.rb +39 -0
  120. data/lib/rails_admin_next/config/fields/types/timestamp.rb +16 -0
  121. data/lib/rails_admin_next/config/fields/types/uuid.rb +15 -0
  122. data/lib/rails_admin_next/config/fields/types.rb +29 -0
  123. data/lib/rails_admin_next/config/fields.rb +88 -0
  124. data/lib/rails_admin_next/config/groupable.rb +20 -0
  125. data/lib/rails_admin_next/config/has_description.rb +14 -0
  126. data/lib/rails_admin_next/config/has_fields.rb +144 -0
  127. data/lib/rails_admin_next/config/has_groups.rb +27 -0
  128. data/lib/rails_admin_next/config/hideable.rb +30 -0
  129. data/lib/rails_admin_next/config/inspectable.rb +39 -0
  130. data/lib/rails_admin_next/config/lazy_model.rb +74 -0
  131. data/lib/rails_admin_next/config/model.rb +126 -0
  132. data/lib/rails_admin_next/config/proxyable/proxy.rb +42 -0
  133. data/lib/rails_admin_next/config/proxyable.rb +26 -0
  134. data/lib/rails_admin_next/config/sections/base.rb +36 -0
  135. data/lib/rails_admin_next/config/sections/create.rb +13 -0
  136. data/lib/rails_admin_next/config/sections/edit.rb +13 -0
  137. data/lib/rails_admin_next/config/sections/export.rb +13 -0
  138. data/lib/rails_admin_next/config/sections/list.rb +63 -0
  139. data/lib/rails_admin_next/config/sections/modal.rb +12 -0
  140. data/lib/rails_admin_next/config/sections/nested.rb +12 -0
  141. data/lib/rails_admin_next/config/sections/show.rb +12 -0
  142. data/lib/rails_admin_next/config/sections/update.rb +12 -0
  143. data/lib/rails_admin_next/config/sections.rb +39 -0
  144. data/lib/rails_admin_next/config.rb +436 -0
  145. data/lib/rails_admin_next/engine.rb +91 -0
  146. data/lib/rails_admin_next/extension.rb +35 -0
  147. data/lib/rails_admin_next/extensions/cancancan/authorization_adapter.rb +90 -0
  148. data/lib/rails_admin_next/extensions/cancancan.rb +5 -0
  149. data/lib/rails_admin_next/extensions/controller_extension.rb +8 -0
  150. data/lib/rails_admin_next/extensions/paper_trail/auditing_adapter.rb +192 -0
  151. data/lib/rails_admin_next/extensions/paper_trail.rb +5 -0
  152. data/lib/rails_admin_next/extensions/pundit/authorization_adapter.rb +72 -0
  153. data/lib/rails_admin_next/extensions/pundit.rb +5 -0
  154. data/lib/rails_admin_next/extensions/url_for_extension.rb +15 -0
  155. data/lib/rails_admin_next/icons.rb +143 -0
  156. data/lib/rails_admin_next/support/composite_keys_serializer.rb +15 -0
  157. data/lib/rails_admin_next/support/csv_converter.rb +100 -0
  158. data/lib/rails_admin_next/support/hash_helper.rb +31 -0
  159. data/lib/rails_admin_next/support/paginated_collection.rb +79 -0
  160. data/lib/rails_admin_next/version.rb +17 -0
  161. data/lib/rails_admin_next.rb +72 -0
  162. data/lib/tasks/rails_admin.rake +40 -0
  163. data/src/rails_admin/application.js +15 -0
  164. data/src/rails_admin/base.js +12 -0
  165. data/src/rails_admin/controllers/alert_controller.js +28 -0
  166. data/src/rails_admin/controllers/collapse_controller.js +16 -0
  167. data/src/rails_admin/controllers/dropdown_controller.js +110 -0
  168. data/src/rails_admin/controllers/feedback_controller.js +73 -0
  169. data/src/rails_admin/controllers/filter_box_controller.js +346 -0
  170. data/src/rails_admin/controllers/filtering_multiselect_controller.js +386 -0
  171. data/src/rails_admin/controllers/filtering_select_controller.js +509 -0
  172. data/src/rails_admin/controllers/index.js +31 -0
  173. data/src/rails_admin/controllers/modal_controller.js +142 -0
  174. data/src/rails_admin/controllers/nested_form_controller.js +117 -0
  175. data/src/rails_admin/controllers/polymorphic_controller.js +26 -0
  176. data/src/rails_admin/controllers/remote_form_controller.js +173 -0
  177. data/src/rails_admin/controllers/sidescroll_controller.js +23 -0
  178. data/src/rails_admin/dom-ready.js +20 -0
  179. data/src/rails_admin/dom.js +19 -0
  180. data/src/rails_admin/i18n.js +20 -0
  181. data/src/rails_admin/icons.js +64 -0
  182. data/src/rails_admin/interactions.js +221 -0
  183. data/src/rails_admin/select_query.js +65 -0
  184. metadata +346 -0
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAdminNext
4
+ class Version
5
+ MAJOR = 1
6
+ MINOR = 0
7
+ PATCH = 0
8
+ PRE = nil
9
+
10
+ class << self
11
+ # @return [String]
12
+ def to_s
13
+ [MAJOR, MINOR, PATCH, PRE].compact.join(".")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_admin_next/engine"
4
+ require "rails_admin_next/abstract_model"
5
+ require "rails_admin_next/config"
6
+ require "rails_admin_next/config/const_load_suppressor"
7
+ require "rails_admin_next/icons"
8
+ require "rails_admin_next/extension"
9
+ require "rails_admin_next/extensions/cancancan"
10
+ require "rails_admin_next/extensions/pundit"
11
+ require "rails_admin_next/extensions/paper_trail"
12
+ require "rails_admin_next/support/csv_converter"
13
+ require "rails_admin_next/support/hash_helper"
14
+ require "rails_admin_next/support/paginated_collection"
15
+ require "yaml"
16
+
17
+ module RailsAdminNext
18
+ extend RailsAdminNext::Config::ConstLoadSuppressor
19
+
20
+ # Setup RailsAdminNext
21
+ #
22
+ # Given the first argument is a model class, a model class name
23
+ # or an abstract model object proxies to model configuration method.
24
+ #
25
+ # If only a block is passed it is stored to initializer stack to be evaluated
26
+ # on first request in production mode and on each request in development. If
27
+ # initialization has already occurred (in other words RailsAdminNext.setup has
28
+ # been called) the block will be added to stack and evaluated at once.
29
+ #
30
+ # Otherwise returns RailsAdminNext::Config class.
31
+ #
32
+ # @see RailsAdminNext::Config
33
+ def self.config(entity = nil, &)
34
+ if entity
35
+ RailsAdminNext::Config.model(entity, &)
36
+ elsif block_given?
37
+ RailsAdminNext::Config::ConstLoadSuppressor.suppressing { yield(RailsAdminNext::Config) }
38
+ else
39
+ RailsAdminNext::Config
40
+ end
41
+ end
42
+
43
+ # Deprecator for RailsAdminNext's own configuration options. Rails 8.1 made the class-level
44
+ # ActiveSupport::Deprecation.warn private, so deprecations go through an instance deprecator.
45
+ # Intentionally left unregistered, so a deprecated-but-removed option warns rather than raising
46
+ # even in a host whose deprecation behavior is :raise — upgrading must never blow up at boot.
47
+ def self.deprecator
48
+ @deprecator ||= ActiveSupport::Deprecation.new(RailsAdminNext::Version.to_s, "RailsAdminNext")
49
+ end
50
+
51
+ # Backwards-compatible with safe_yaml/load when SafeYAML isn't available.
52
+ # Evaluates available YAML loaders at boot and creates appropriate method,
53
+ # so no conditionals are required at runtime.
54
+ begin
55
+ require "safe_yaml/load"
56
+ def self.yaml_load(yaml)
57
+ SafeYAML.load(yaml)
58
+ end
59
+ rescue LoadError
60
+ if YAML.respond_to?(:safe_load)
61
+ def self.yaml_load(yaml)
62
+ YAML.safe_load(yaml)
63
+ end
64
+ else
65
+ raise LoadError.new "Safe-loading of YAML is not available. Please install 'safe_yaml' or install Psych 2.0+"
66
+ end
67
+ end
68
+
69
+ def self.yaml_dump(object)
70
+ YAML.dump(object)
71
+ end
72
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :rails_admin do
4
+ desc "Install rails_admin_next"
5
+ task :install do
6
+ system "rails g rails_admin_next:install"
7
+ end
8
+
9
+ desc "CI env for GitHub Actions"
10
+ task :prepare_ci_env do
11
+ adapter = ENV["CI_DB_ADAPTER"] || "sqlite3"
12
+ database = ((adapter == "sqlite3") ? "db/development.sqlite3" : "ci_rails_admin_next")
13
+ username =
14
+ case adapter
15
+ when "postgresql"
16
+ "postgres"
17
+ when "mysql2"
18
+ "root"
19
+ else
20
+ ""
21
+ end
22
+
23
+ configuration = {
24
+ "test" => {
25
+ "adapter" => adapter,
26
+ "database" => database,
27
+ "username" => username,
28
+ "password" => ((adapter == "postgresql") ? "postgres" : ""),
29
+ "host" => "127.0.0.1",
30
+ "encoding" => "utf8",
31
+ "pool" => 5,
32
+ "timeout" => 5000
33
+ }
34
+ }
35
+
36
+ filename = Rails.root.join("config/database.yml")
37
+
38
+ File.write(filename, configuration.to_yaml)
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ import { Application } from "@hotwired/stimulus";
2
+
3
+ // RailsAdminNext runs its OWN Stimulus application, separate from any Stimulus app the
4
+ // host may run for its own UI. Controllers are registered against this instance
5
+ // (see ./controllers/index.js) so the engine never reaches into — or depends on —
6
+ // the host's controllers/ directory.
7
+ const application = Application.start();
8
+ application.warnings = false;
9
+ application.debug = false;
10
+
11
+ // Expose for debugging and host extension under the engine namespace.
12
+ window.RailsAdminNext = window.RailsAdminNext || {};
13
+ window.RailsAdminNext.application = application;
14
+
15
+ export { application };
@@ -0,0 +1,12 @@
1
+ import "@hotwired/turbo-rails";
2
+
3
+ // RailsAdminNext's own Stimulus application + controllers.
4
+ import "rails_admin/application";
5
+ import "rails_admin/controllers";
6
+
7
+ // Vanilla, jQuery-free document-level page interactions.
8
+ import "rails_admin/interactions";
9
+
10
+ // Owns rails_admin.dom_ready. Imported last so every listener registered above
11
+ // is in place before the first dispatch fires.
12
+ import "rails_admin/dom-ready";
@@ -0,0 +1,28 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ const FOCUSABLE =
4
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
5
+
6
+ // Dismissible flash alert. Replaces Bootstrap's data-bs-dismiss="alert".
7
+ // Removing an alert with the keyboard would otherwise drop focus to <body>, so
8
+ // on dismiss focus returns to the nearest focusable element preceding the alert
9
+ // (falling back to the first focusable element on the page).
10
+ export default class extends Controller {
11
+ dismiss() {
12
+ const target = this.#focusReturnTarget();
13
+ this.element.remove();
14
+ target?.focus();
15
+ }
16
+
17
+ #focusReturnTarget() {
18
+ const focusable = Array.from(document.querySelectorAll(FOCUSABLE)).filter(
19
+ (el) => !this.element.contains(el) && el.offsetParent !== null
20
+ );
21
+ const preceding = focusable.filter(
22
+ (el) =>
23
+ this.element.compareDocumentPosition(el) &
24
+ Node.DOCUMENT_POSITION_PRECEDING
25
+ );
26
+ return preceding.at(-1) || focusable[0] || null;
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Responsive navbar toggler (WAI-ARIA disclosure): the hamburger toggles
4
+ // `aria-expanded` and the region's Bootstrap `.collapse`/`.show` classes
5
+ // (`.collapse:not(.show)` is hidden; `.navbar-expand-md` forces the region
6
+ // visible above the breakpoint). The sidebar groups use native <details>, but a
7
+ // <details> navbar can't be force-expanded at the md breakpoint, so the navbar
8
+ // stays class-driven.
9
+ export default class extends Controller {
10
+ static targets = ["trigger", "region"];
11
+
12
+ toggle() {
13
+ const expanded = this.regionTarget.classList.toggle("show");
14
+ this.triggerTarget.setAttribute("aria-expanded", String(expanded));
15
+ }
16
+ }
@@ -0,0 +1,110 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ const ITEM = ".dropdown-item";
4
+
5
+ // Menu button (WAI-ARIA APG) on a native popover. The toggle is a
6
+ // <button popovertarget> and the menu carries popover="auto", so the platform
7
+ // owns the top layer, click-to-toggle, and outside-click/Escape light
8
+ // dismissal (do NOT reimplement those). The controller lives on the toggle;
9
+ // its menu is the next sibling (`.dropdown-menu`), so the menu items stay out
10
+ // of Stimulus's action-parsing scope — important because the bulk-action
11
+ // items carry their own `data-action` attribute read by interactions.js.
12
+ //
13
+ // What is left for JS:
14
+ // - ArrowDown/ArrowUp on the closed toggle open the menu onto its
15
+ // first/last item; opening any other way focuses the first item.
16
+ // - ArrowUp/Down/Home/End rove focus between items; Tab closes.
17
+ // - an item click closes the menu (clicks inside a popover don't dismiss).
18
+ // - aria-expanded on the toggle syncs from the menu's popover toggle event,
19
+ // and focus returns to the toggle when a light dismissal dropped it on
20
+ // <body> (Escape restores it natively; an outside click on a
21
+ // non-focusable target does not).
22
+ export default class extends Controller {
23
+ #focusOnOpen = "first";
24
+ #onToggle;
25
+ #onMenuKeydown;
26
+ #onMenuClick;
27
+
28
+ connect() {
29
+ this.element.setAttribute("aria-haspopup", "true");
30
+ // Also resets a stale value restored from the Turbo page cache (popovers
31
+ // are snapshotted closed, but the attribute travels as-is).
32
+ this.element.setAttribute("aria-expanded", "false");
33
+ this.#onToggle = this.#toggled.bind(this);
34
+ this.#onMenuKeydown = this.#menuKeydown.bind(this);
35
+ this.#onMenuClick = this.#menuClick.bind(this);
36
+ this.#menu.addEventListener("toggle", this.#onToggle);
37
+ this.#menu.addEventListener("keydown", this.#onMenuKeydown);
38
+ this.#menu.addEventListener("click", this.#onMenuClick);
39
+ }
40
+
41
+ disconnect() {
42
+ this.#menu?.removeEventListener("toggle", this.#onToggle);
43
+ this.#menu?.removeEventListener("keydown", this.#onMenuKeydown);
44
+ this.#menu?.removeEventListener("click", this.#onMenuClick);
45
+ }
46
+
47
+ // Enter, Space and click toggle natively via [popovertarget]; the arrows
48
+ // open with an explicit landing item.
49
+ onKeydown(event) {
50
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
51
+ event.preventDefault();
52
+ if (this.#menu.matches(":popover-open")) return;
53
+ this.#focusOnOpen = event.key === "ArrowUp" ? "last" : "first";
54
+ this.#menu.showPopover();
55
+ }
56
+
57
+ get #menu() {
58
+ return this.element.nextElementSibling;
59
+ }
60
+
61
+ get #items() {
62
+ return Array.from(this.#menu.querySelectorAll(ITEM));
63
+ }
64
+
65
+ // Popover toggle events fire for every show/hide path — the toggle button,
66
+ // light dismissal, hidePopover(), even a <dialog> claiming the top layer.
67
+ #toggled(event) {
68
+ const open = event.newState === "open";
69
+ this.element.setAttribute("aria-expanded", String(open));
70
+ if (open) {
71
+ const items = this.#items;
72
+ (this.#focusOnOpen === "last" ? items.at(-1) : items[0])?.focus();
73
+ this.#focusOnOpen = "first";
74
+ } else if (document.activeElement === document.body) {
75
+ this.element.focus();
76
+ }
77
+ }
78
+
79
+ #menuKeydown(event) {
80
+ const items = this.#items;
81
+ const current = items.indexOf(document.activeElement);
82
+ switch (event.key) {
83
+ case "ArrowDown":
84
+ event.preventDefault();
85
+ items[(current + 1) % items.length]?.focus();
86
+ break;
87
+ case "ArrowUp":
88
+ event.preventDefault();
89
+ items[(current - 1 + items.length) % items.length]?.focus();
90
+ break;
91
+ case "Home":
92
+ event.preventDefault();
93
+ items[0]?.focus();
94
+ break;
95
+ case "End":
96
+ event.preventDefault();
97
+ items.at(-1)?.focus();
98
+ break;
99
+ case "Tab":
100
+ // hidePopover() restores focus to the toggle first, so the default
101
+ // Tab then moves on from there — the APG menu-button behavior.
102
+ this.#menu.hidePopover();
103
+ break;
104
+ }
105
+ }
106
+
107
+ #menuClick(event) {
108
+ if (event.target.closest(ITEM)) this.#menu.hidePopover();
109
+ }
110
+ }
@@ -0,0 +1,73 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ const BUSY_CLASS = "rails_admin-submitting";
4
+
5
+ // Shared UX-feedback for Turbo-driven forms. Wired onto every admin form by
6
+ // RailsAdminNext::MainHelper#rails_admin_form_for as:
7
+ //
8
+ // data-controller="feedback"
9
+ // data-action="turbo:submit-start->feedback#start turbo:submit-end->feedback#end"
10
+ //
11
+ // `start` disables the form's submit controls and marks the form busy; `end`
12
+ // reverses both symmetrically. Turbo fires `turbo:submit-end` on success AND on a
13
+ // 422 validation failure, so the form never gets stuck disabled. State is driven
14
+ // entirely by the Turbo submit lifecycle — there are no fixed timeouts.
15
+ //
16
+ // On connect it also flags every errored field `aria-invalid="true"` and moves
17
+ // focus to the first one, so a 422 re-render (which Turbo renders in place,
18
+ // re-running connect) lands the cursor on the field that needs fixing —
19
+ // including fields inside nested subforms.
20
+ export default class extends Controller {
21
+ connect() {
22
+ // A back/forward-cache restore can replay a snapshot captured mid-submit;
23
+ // start every connection from a clean, non-busy state.
24
+ this.#reset();
25
+ this.#flagInvalidFields();
26
+ }
27
+
28
+ start() {
29
+ this.element.setAttribute("aria-busy", "true");
30
+ this.element.classList.add(BUSY_CLASS);
31
+ this.#submitControls.forEach((control) => {
32
+ control.disabled = true;
33
+ this.#disabledControls.add(control);
34
+ });
35
+ }
36
+
37
+ end() {
38
+ this.#reset();
39
+ }
40
+
41
+ // RailsAdminNext marks an errored field's wrapper `.control-group.error`; expose
42
+ // that on the inputs themselves as `aria-invalid` and focus the first. Focus
43
+ // is only taken from a freshly rendered page (activeElement is the body), so
44
+ // a user already typing somewhere is never interrupted.
45
+ #flagInvalidFields() {
46
+ const invalid = this.element.querySelectorAll(
47
+ ".control-group.error input:not([type='hidden']), .control-group.error select, .control-group.error textarea"
48
+ );
49
+ invalid.forEach((field) => field.setAttribute("aria-invalid", "true"));
50
+
51
+ const focusable = document.activeElement;
52
+ if (invalid[0] && (!focusable || focusable === document.body)) {
53
+ invalid[0].focus();
54
+ }
55
+ }
56
+
57
+ #reset() {
58
+ this.element.removeAttribute("aria-busy");
59
+ this.element.classList.remove(BUSY_CLASS);
60
+ this.#disabledControls.forEach((control) => {
61
+ control.disabled = false;
62
+ });
63
+ this.#disabledControls.clear();
64
+ }
65
+
66
+ get #submitControls() {
67
+ return this.element.querySelectorAll(
68
+ 'input[type="submit"], button[type="submit"], button:not([type])'
69
+ );
70
+ }
71
+
72
+ #disabledControls = new Set();
73
+ }