decidim-core 0.30.6 → 0.30.8

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/content_blocks/highlighted_elements_with_cell_for_list_cell.rb +5 -1
  3. data/app/cells/decidim/content_blocks/participatory_space_metadata/content.erb +2 -2
  4. data/app/cells/decidim/report_button/already_reported_modal.erb +1 -1
  5. data/app/cells/decidim/report_button/flag_modal.erb +1 -1
  6. data/app/cells/decidim/report_user_button/already_reported_modal.erb +1 -1
  7. data/app/cells/decidim/report_user_button/flag_modal.erb +1 -1
  8. data/app/cells/decidim/statistic/show.erb +4 -4
  9. data/app/cells/decidim/upload_modal/files.erb +9 -5
  10. data/app/cells/decidim/upload_modal_cell.rb +14 -1
  11. data/app/commands/decidim/multiple_attachments_methods.rb +20 -3
  12. data/app/helpers/decidim/mailer_helper.rb +36 -0
  13. data/app/helpers/decidim/menu_helper.rb +2 -1
  14. data/app/helpers/decidim/newsletters_helper.rb +4 -22
  15. data/app/jobs/decidim/find_and_update_descendants_job.rb +8 -2
  16. data/app/jobs/decidim/update_search_indexes_job.rb +2 -2
  17. data/app/mailers/decidim/application_mailer.rb +4 -0
  18. data/app/packs/src/decidim/a11y.js +29 -0
  19. data/app/packs/src/decidim/a11y.test.js +81 -0
  20. data/app/packs/src/decidim/confirm.js +8 -1
  21. data/app/packs/src/decidim/confirm.test.js +225 -0
  22. data/app/packs/src/decidim/datepicker/datepicker_functions.js +26 -0
  23. data/app/packs/src/decidim/datepicker/generate_datepicker.js +2 -1
  24. data/app/packs/src/decidim/datepicker/generate_timepicker.js +9 -1
  25. data/app/packs/src/decidim/datepicker/test/datepicker_functions_adjust_picker_position.test.js +234 -0
  26. data/app/packs/src/decidim/direct_uploads/upload_field.js +1 -1
  27. data/app/packs/src/decidim/focus_guard.js +4 -4
  28. data/app/packs/stylesheets/decidim/_cards.scss +12 -4
  29. data/app/packs/stylesheets/decidim/_flash.scss +1 -1
  30. data/app/packs/stylesheets/decidim/_modal_update.scss +1 -1
  31. data/app/presenters/decidim/menu_item_presenter.rb +7 -1
  32. data/app/views/decidim/devise/invitations/edit.html.erb +3 -3
  33. data/app/views/decidim/devise/registrations/new.html.erb +1 -0
  34. data/app/views/decidim/devise/shared/_tos_fields.html.erb +3 -3
  35. data/app/views/decidim/notification_mailer/event_received.html.erb +3 -3
  36. data/config/initializers/devise.rb +6 -0
  37. data/config/locales/ar.yml +3 -3
  38. data/config/locales/bg.yml +0 -4
  39. data/config/locales/ca-IT.yml +7 -6
  40. data/config/locales/ca.yml +7 -6
  41. data/config/locales/cs.yml +8 -8
  42. data/config/locales/de.yml +17 -8
  43. data/config/locales/el.yml +0 -2
  44. data/config/locales/en.yml +5 -4
  45. data/config/locales/es-MX.yml +8 -7
  46. data/config/locales/es-PY.yml +8 -7
  47. data/config/locales/es.yml +10 -9
  48. data/config/locales/eu.yml +8 -6
  49. data/config/locales/fi-plain.yml +10 -4
  50. data/config/locales/fi.yml +11 -5
  51. data/config/locales/fr-CA.yml +7 -5
  52. data/config/locales/fr.yml +8 -7
  53. data/config/locales/gl.yml +0 -2
  54. data/config/locales/hu.yml +5 -9
  55. data/config/locales/id-ID.yml +0 -2
  56. data/config/locales/it.yml +1 -3
  57. data/config/locales/ja.yml +7 -8
  58. data/config/locales/lb.yml +0 -2
  59. data/config/locales/lt.yml +1 -3
  60. data/config/locales/lv.yml +0 -2
  61. data/config/locales/nl.yml +0 -2
  62. data/config/locales/no.yml +0 -2
  63. data/config/locales/pl.yml +0 -4
  64. data/config/locales/pt-BR.yml +4 -5
  65. data/config/locales/pt.yml +0 -2
  66. data/config/locales/ro-RO.yml +1 -5
  67. data/config/locales/ru.yml +0 -2
  68. data/config/locales/sk.yml +0 -4
  69. data/config/locales/sv.yml +8 -7
  70. data/config/locales/tr-TR.yml +17 -5
  71. data/config/locales/zh-CN.yml +0 -2
  72. data/config/locales/zh-TW.yml +1 -3
  73. data/lib/decidim/assets/tailwind/tailwind.config.js.erb +1 -1
  74. data/lib/decidim/content_parsers/blob_parser.rb +2 -2
  75. data/lib/decidim/content_renderers/blob_renderer.rb +2 -2
  76. data/lib/decidim/core/version.rb +1 -1
  77. data/lib/decidim/form_builder.rb +25 -1
  78. data/lib/decidim/maintenance/taxonomy_importer.rb +1 -1
  79. data/lib/decidim/searchable.rb +4 -4
  80. metadata +10 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d2d2e573802976d0b68a5ec56528a5ba499416c443101bba31fee2d2f1af9e6
4
- data.tar.gz: f172339288312c74d88a376805b12eed9aea1ea97c7e049978c3d94a4ab99f38
3
+ metadata.gz: b31c19b0f1263634559541d974f68dba477328f79a493ab4188eb3d8cb42a0eb
4
+ data.tar.gz: a9d06e9ea81dfe61b62c9f99961a4284af380a5343a12ce5c7ca7b055f4041b8
5
5
  SHA512:
6
- metadata.gz: eb126980873b650eaa4c89d208bbd0d266df4da01196f60621211e896501e615329f1e35e34251c66f8e7f67b71d4df6cfaefe38eb4343f69d7d65437b62f51e
7
- data.tar.gz: 3204e02c1097ee49b967bfd558fd51b32f3faa6d3ee9352590e6c98c0020c86590c383756b0b5694c97fe3e4b6689fcbecb422e15b2bae929656533a5908af9b
6
+ metadata.gz: 4c821103cefa1cfc8c3688fe69dce51bd2e6c94a448bee4fdc05ef5abd08e522cc4785e0827217b464b9b4a601cb52daa8539b21b0bf98f39e69fdff1b5824a5
7
+ data.tar.gz: 9c27f4b8ce372ac3295e719a3985b7276ec91d90303b4e627545d4571012936d3d4f1694c6e81e4df95fac154bbb2aaa3889130b9ffcfc609a1b2a40a699a99f
@@ -19,10 +19,14 @@ module Decidim
19
19
  @list_cell ||= cell(
20
20
  list_cell_path,
21
21
  published_components.one? ? published_components.first : published_components,
22
- **model.settings.to_h, see_all_path:
22
+ **model.settings.to_h, **extra_list_cell_options, see_all_path:
23
23
  )
24
24
  end
25
25
 
26
+ def extra_list_cell_options
27
+ {}
28
+ end
29
+
26
30
  def see_all_path; end
27
31
  end
28
32
  end
@@ -2,9 +2,9 @@
2
2
  <% metadata_valued_items.each do |item| %>
3
3
  <div class="participatory-space__metadata-item">
4
4
  <div class="participatory-space__metadata-item-title">
5
- <span><%= item[:title] %></span>
5
+ <p><%= item[:title] %></p>
6
6
  </div>
7
- <span><%= item[:value] %></span>
7
+ <p><%= item[:value] %></p>
8
8
  </div>
9
9
  <% end %>
10
10
  </div>
@@ -1,7 +1,7 @@
1
1
  <%= decidim_modal id: modal_id, class: "flag-modal" do %>
2
2
  <div data-dialog-container>
3
3
  <%= icon "flag-line" %>
4
- <h2 tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_modal.title") %></h2>
4
+ <h2 id="dialog-title-<%= modal_id %>" tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_modal.title") %></h2>
5
5
  <div>
6
6
  <div class="form__wrapper flag-modal__form">
7
7
  <p class="flag-modal__form-description"><%= t("decidim.shared.flag_modal.already_reported") %></p>
@@ -2,7 +2,7 @@
2
2
  <%= form_for report_form, builder:, url: report_path, method: :post, html: { id: nil } do |f| %>
3
3
  <div data-dialog-container>
4
4
  <%= icon "flag-line" %>
5
- <h2 tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_modal.title") %></h2>
5
+ <h2 id="dialog-title-<%= modal_id %>" tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_modal.title") %></h2>
6
6
  <div>
7
7
  <div class="form__wrapper flag-modal__form">
8
8
  <p id="dialog-desc-<%= modal_id %>" class="flag-modal__form-description"><%= t("decidim.shared.flag_modal.description") %></p>
@@ -1,7 +1,7 @@
1
1
  <%= decidim_modal id: modal_id, class: "flag-user-modal" do %>
2
2
  <div data-dialog-container>
3
3
  <%= icon "flag-line" %>
4
- <h2 tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_user_modal.title") %></h2>
4
+ <h2 id="dialog-title-<%= modal_id %>" tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_user_modal.title") %></h2>
5
5
  <div>
6
6
  <div class="form__wrapper flag-modal__form">
7
7
  <p class="flag-modal__form-description"><%= t("decidim.shared.flag_user_modal.already_reported") %></p>
@@ -2,7 +2,7 @@
2
2
  <%= form_for report_form, builder:, url: report_path, method: :post, html: { id: nil } do |f| %>
3
3
  <div data-dialog-container>
4
4
  <%= icon "flag-line" %>
5
- <h2 tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_user_modal.title") %></h2>
5
+ <h2 id="dialog-title-<%= modal_id %>" tabindex="-1" data-dialog-title><%= t("decidim.shared.flag_user_modal.title") %></h2>
6
6
  <div>
7
7
  <div class="form__wrapper flag-modal__form">
8
8
  <p class="flag-modal__form-description"><%= t("decidim.shared.flag_user_modal.description") %></p>
@@ -1,8 +1,8 @@
1
1
  <div class="statistic <%= stat_dom_class %>" data-statistic>
2
- <span class="statistic__title" title="<%= stat_title %>">
2
+ <p class="statistic__title" title="<%= stat_title %>">
3
3
  <%= stat_title %>
4
- </span>
5
- <span class="statistic__number">
4
+ </p>
5
+ <p class="statistic__number">
6
6
  <%= stat_number %>
7
- </span>
7
+ </p>
8
8
  </div>
@@ -1,9 +1,9 @@
1
1
  <div class="upload-modal__files-container upload-container-for-<%= attribute %> <%= with_title %>">
2
2
  <div>
3
- <%= label %>
3
+ <%= options[:paragraph] == true ? paragraph : label %>
4
4
 
5
5
  <% if options[:help_text].present? %>
6
- <span class="help-text"><%= options[:help_text] %></span>
6
+ <p class="help-text"><%= options[:help_text] %></p>
7
7
  <% end %>
8
8
 
9
9
  <%# NOTE: this block is about wrapping a default image for the avatar with the new styles,
@@ -33,16 +33,20 @@
33
33
  <% end %>
34
34
 
35
35
  <% if has_title? %>
36
- <span><%= title_for(attachment) %></span>
36
+ <p><%= title_for(attachment) %></p>
37
37
  <% else %>
38
38
  <% if attachment_blob&.image? %>
39
- <span><%= title_for(attachment) %></span>
39
+ <p><%= title_for(attachment) %></p>
40
40
  <% else %>
41
41
  <%= link_to title_for(attachment), file_attachment_path(attachment), class: "w-full break-all mb-2" %>
42
42
  <% end %>
43
43
  <% end %>
44
44
  <% if attachment_blob.present? %>
45
- <%= form.hidden_field attribute, value: attachment_blob.signed_id, id: "hidden_#{attribute}_#{attachment_blob.id}" %>
45
+ <% if is_persisted_attachment %>
46
+ <%= form.hidden_field attribute, value: attachment.id, id: "hidden_#{attribute}_#{attachment.id}" %>
47
+ <% else %>
48
+ <%= form.hidden_field attribute, value: attachment_blob.signed_id, id: "hidden_#{attribute}_#{attachment_blob.id}" %>
49
+ <% end %>
46
50
  <% end %>
47
51
  </div>
48
52
  <% end %>
@@ -30,6 +30,10 @@ module Decidim
30
30
  form.send(:custom_label, attribute, options[:label], { required: required?, for: nil })
31
31
  end
32
32
 
33
+ def paragraph
34
+ form.send(:custom_paragraph, attribute, options[:label], { required: required? })
35
+ end
36
+
33
37
  def button_label
34
38
  return button_edit_label if attachments.count.positive?
35
39
 
@@ -124,7 +128,16 @@ module Decidim
124
128
  @attachments = begin
125
129
  attachments = options[:attachments] || form.object.send(attribute)
126
130
  attachments = Array(attachments).compact_blank
127
- attachments.map { |attachment| attachment.is_a?(String) ? ActiveStorage::Blob.find_signed(attachment) : attachment }
131
+ attachments.map do |attachment|
132
+ case attachment
133
+ when String
134
+ ActiveStorage::Blob.find_signed(attachment)
135
+ when Integer
136
+ Decidim::Attachment.find_by(id: attachment)
137
+ else
138
+ attachment
139
+ end
140
+ end.compact
128
141
  end
129
142
  end
130
143
 
@@ -41,8 +41,9 @@ module Decidim
41
41
 
42
42
  def create_attachments(first_weight: 0)
43
43
  weight = first_weight
44
- # Add the weights first to the old document
45
- @form.documents.each do |document|
44
+ # Add the weights first to the old documents
45
+ document_ids = keep_ids
46
+ Decidim::Attachment.where(id: document_ids).each do |document|
46
47
  document.update!(weight:)
47
48
  weight += 1
48
49
  end
@@ -59,7 +60,7 @@ module Decidim
59
60
  documents = include_all_attachments ? documents_attached_to.attachments.with_attached_file : documents_attached_to.documents
60
61
 
61
62
  documents.each do |document|
62
- document.destroy! if @form.documents.map(&:id).exclude? document.id
63
+ document.destroy! unless keep_ids.include?(document.id)
63
64
  end
64
65
 
65
66
  documents_attached_to.reload
@@ -98,5 +99,21 @@ module Decidim
98
99
  def blob(signed_id)
99
100
  ActiveStorage::Blob.find_signed(signed_id)
100
101
  end
102
+
103
+ def keep_ids
104
+ documents_array = Array(@form.documents)
105
+ documents_array.map do |doc|
106
+ case doc
107
+ when Decidim::Attachment
108
+ doc.id
109
+ when Integer
110
+ doc
111
+ when String
112
+ doc.match?(/\A\d+\z/) ? doc.to_i : nil
113
+ when Hash
114
+ (doc[:id] || doc["id"]).to_i
115
+ end
116
+ end.compact
117
+ end
101
118
  end
102
119
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ # Helper that provides methods to render order selector and links
5
+ module MailerHelper
6
+ # Transforms relative image URLs in HTML content to absolute URLs using the provided host.
7
+ # This is used in emails (newsletters and notifications) to ensure images display correctly
8
+ # in email clients.
9
+ #
10
+ # @param content [String] - HTML content with img tags
11
+ # @param host [String] - the Decidim::Organization host to use for the root URL
12
+ #
13
+ # @return [String] - the content with transformed image URLs
14
+ def decidim_transform_image_urls(content, host)
15
+ return content if host.blank? || content.blank?
16
+
17
+ root_url = if Rails.application.secrets.dig(:storage, :cdn_host).present?
18
+ Rails.application.secrets.dig(:storage, :cdn_host).chomp("/")
19
+ else
20
+ Decidim::EngineRouter.new("decidim", {}).root_url(host:).chomp("/")
21
+ end
22
+
23
+ content.gsub(/src\s*=\s*(['"])([^'"]*)\1/) do
24
+ quote = Regexp.last_match(1)
25
+ src_value = Regexp.last_match(2)
26
+
27
+ if src_value.blank? || src_value.start_with?("http://", "https://", "data:", "//", "cid:")
28
+ %(src=#{quote}#{src_value}#{quote})
29
+ else
30
+ normalized_src = src_value.start_with?("/") ? src_value : "/#{src_value}"
31
+ %(src=#{quote}#{root_url}#{normalized_src}#{quote})
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -57,7 +57,8 @@ module Decidim
57
57
  self,
58
58
  element_class: "font-semibold underline",
59
59
  active_class: "is-active",
60
- container_options: { class: "space-y-4 break-inside-avoid", role: :menu },
60
+ role: false,
61
+ container_options: { class: "space-y-4 break-inside-avoid" },
61
62
  label: t("layouts.decidim.footer.decidim_title")
62
63
  )
63
64
  end
@@ -3,6 +3,9 @@
3
3
  module Decidim
4
4
  # Helper that provides methods to render links with utm codes, and replaced name
5
5
  module NewslettersHelper
6
+ include Decidim::SanitizeHelper
7
+ include Decidim::MailerHelper
8
+
6
9
  # If the newsletter body there are some links and the Decidim.track_newsletter_links = true
7
10
  # it will be replaced with the utm_codes method described below.
8
11
  # for example transform "https://es.lipsum.com/" to "https://es.lipsum.com/?utm_source=localhost&utm_campaign=newsletter_11"
@@ -19,7 +22,7 @@ module Decidim
19
22
 
20
23
  content = interpret_name(content, user)
21
24
  content = track_newsletter_links(content, id, host)
22
- transform_image_urls(content, host)
25
+ decidim_transform_image_urls(content, host)
23
26
  end
24
27
 
25
28
  # this method is used to generate the root link on mail with the utm_codes
@@ -67,27 +70,6 @@ module Decidim
67
70
  content.gsub("%{name}", user.name)
68
71
  end
69
72
 
70
- # Find each img HTML tag with relative path in src attribute
71
- # For each URL, prepends the decidim.root_url
72
- # If host is not defined it returns full content
73
- #
74
- # @param content [String] - the string to convert
75
- # @param host [String] - the Decidim::Organization host to replace
76
- #
77
- # @return [String] - the content converted
78
- #
79
- def transform_image_urls(content, host)
80
- return content if host.blank?
81
-
82
- content.scan(/src\s*=\s*"([^"]*)"/).each do |src|
83
- root_url = decidim.root_url(host:)[0..-2]
84
- src_replaced = "#{root_url}#{src.first}"
85
- content = content.gsub(/src\s*=\s*"([^"]*#{src.first})"/, %(src="#{src_replaced}"))
86
- end
87
-
88
- content
89
- end
90
-
91
73
  # Add tracking query params to each links
92
74
  #
93
75
  # @param content [String] - the string to convert
@@ -4,8 +4,14 @@ module Decidim
4
4
  # Update search indexes for each descendants of a given element
5
5
  class FindAndUpdateDescendantsJob < ApplicationJob
6
6
  queue_as :default
7
+ MAX_DEPTH = 5
8
+
9
+ def perform(element, current_depth = 0)
10
+ if current_depth >= MAX_DEPTH
11
+ Rails.logger.warn "Max depth of #{MAX_DEPTH} reached for element #{element.class.name} with id #{element.id}. Stopping recursion."
12
+ return
13
+ end
7
14
 
8
- def perform(element)
9
15
  descendants_collector = components_for(element)
10
16
  descendants_collector << element.comments.to_a if element.respond_to?(:comments)
11
17
 
@@ -14,7 +20,7 @@ module Decidim
14
20
  descendants_collector.each do |descendants|
15
21
  next if descendants.blank?
16
22
 
17
- Decidim::UpdateSearchIndexesJob.perform_later(descendants)
23
+ Decidim::UpdateSearchIndexesJob.perform_later(descendants, current_depth + 1)
18
24
  end
19
25
  end
20
26
 
@@ -4,8 +4,8 @@ module Decidim
4
4
  class UpdateSearchIndexesJob < ApplicationJob
5
5
  queue_as :default
6
6
 
7
- def perform(elements)
8
- elements.each { |element| element.try(:try_update_index_for_search_resource) }
7
+ def perform(elements, current_depth = 0)
8
+ elements.each { |element| element.try(:try_update_index_for_search_resource, current_depth) }
9
9
  end
10
10
  end
11
11
  end
@@ -7,9 +7,13 @@ module Decidim
7
7
  include LocalisedMailer
8
8
  include MultitenantAssetHost
9
9
  include Decidim::SanitizeHelper
10
+ include Decidim::MailerHelper
10
11
  include Decidim::OrganizationHelper
11
12
  helper_method :organization_name, :decidim_escape_translated, :decidim_sanitize_translated, :translated_attribute, :decidim_sanitize, :decidim_sanitize_newsletter
12
13
 
14
+ helper Decidim::SanitizeHelper
15
+ helper Decidim::MailerHelper
16
+
13
17
  after_action :set_smtp
14
18
  after_action :set_from
15
19
 
@@ -124,6 +124,13 @@ const createDropdown = (component) => {
124
124
  * @return {void}
125
125
  */
126
126
  const createDialog = (component) => {
127
+ const getFocusableElements = (container) => {
128
+ const selectors = "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1'])";
129
+ return Array.from(container.querySelectorAll(selectors)).filter(
130
+ (el) => el.offsetParent !== null
131
+ );
132
+ };
133
+
127
134
  const {
128
135
  dataset: { dialog, ...attrs }
129
136
  } = component;
@@ -145,11 +152,33 @@ const createDialog = (component) => {
145
152
  backdropSelector: `[data-dialog="${dialog}"]`,
146
153
  enableAutoFocus: false,
147
154
  onOpen: (params, trigger) => {
155
+ const keyHandler = (event) => {
156
+ if (event.key !== "Tab") {
157
+ return;
158
+ }
159
+ const focusable = getFocusableElements(params);
160
+ if (focusable.length === 0) {
161
+ return;
162
+ }
163
+ if (event.shiftKey && document.activeElement === focusable[0]) {
164
+ event.preventDefault();
165
+ focusable[focusable.length - 1].focus({ preventScroll: true });
166
+ } else if (!event.shiftKey && document.activeElement === focusable[focusable.length - 1]) {
167
+ event.preventDefault();
168
+ focusable[0].focus({ preventScroll: true });
169
+ }
170
+ };
171
+ params._focusTrapHandler = keyHandler;
172
+ params.addEventListener("keydown", keyHandler);
148
173
  setFocusOnTitle(params);
149
174
  window.focusGuard.trap(params, trigger);
150
175
  params.dispatchEvent(new CustomEvent("open.dialog"));
151
176
  },
152
177
  onClose: (params) => {
178
+ if (params._focusTrapHandler) {
179
+ params.removeEventListener("keydown", params._focusTrapHandler);
180
+ Reflect.deleteProperty(params, "_focusTrapHandler");
181
+ }
153
182
  window.focusGuard.disable();
154
183
  params.dispatchEvent(new CustomEvent("close.dialog"));
155
184
  },
@@ -0,0 +1,81 @@
1
+ /* global jest */
2
+
3
+ import { createDialog } from "src/decidim/a11y"
4
+
5
+ describe("a11y dialog focus trap", () => {
6
+ const dialogHtml = `
7
+ <button id="open-btn" data-dialog-open="testDialog">Open</button>
8
+ <div id="test-dialog" data-dialog="testDialog">
9
+ <div data-dialog-container>
10
+ <h2 id="dialog-title-testDialog" tabindex="-1" data-dialog-title>Test Dialog</h2>
11
+ <a href="#">Link 1</a>
12
+ <button>Button 1</button>
13
+ <input type="text" />
14
+ <button>Button 2</button>
15
+ <a href="#">Link 2</a>
16
+ </div>
17
+ <div data-dialog-actions>
18
+ <button data-dialog-close="testDialog">Close</button>
19
+ </div>
20
+ </div>
21
+ `;
22
+
23
+ beforeEach(() => {
24
+ document.body.innerHTML = dialogHtml;
25
+ window.Decidim = {
26
+ currentDialogs: {}
27
+ };
28
+ window.focusGuard = {
29
+ trap: jest.fn(),
30
+ disable: jest.fn()
31
+ };
32
+ });
33
+
34
+ describe("keydown handler", () => {
35
+ let dialogEl = null;
36
+
37
+ beforeEach(() => {
38
+ const component = document.querySelector("[data-dialog]");
39
+ createDialog(component);
40
+ dialogEl = document.querySelector("[data-dialog='testDialog']");
41
+ // Get the dialog from Decidim.currentDialogs and open it
42
+ const dialog = window.Decidim.currentDialogs.testDialog;
43
+ dialog.open();
44
+ });
45
+
46
+ it("adds keydown handler on open", () => {
47
+ expect(dialogEl._focusTrapHandler).toBeDefined();
48
+ });
49
+
50
+ it("handles Tab key", () => {
51
+ const selectors = "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1'])";
52
+ const tabbableElements = Array.from(dialogEl.querySelectorAll(selectors)).filter(
53
+ (el) => el.offsetParent || el.offsetParent === null
54
+ );
55
+ tabbableElements[tabbableElements.length - 1].focus();
56
+
57
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true });
58
+ const preventDefault = jest.fn();
59
+ event.preventDefault = preventDefault;
60
+
61
+ dialogEl.dispatchEvent(event);
62
+
63
+ expect(preventDefault).toHaveBeenCalled();
64
+ });
65
+ });
66
+
67
+ describe("onClose cleanup", () => {
68
+ it("removes the keydown handler on close", () => {
69
+ const component = document.querySelector("[data-dialog]");
70
+ createDialog(component);
71
+ const dialogEl = document.querySelector("[data-dialog='testDialog']");
72
+
73
+ const dialog = window.Decidim.currentDialogs.testDialog;
74
+ dialog.open();
75
+ expect(dialogEl._focusTrapHandler).toBeDefined();
76
+
77
+ dialog.close();
78
+ expect(dialogEl._focusTrapHandler).toBeUndefined();
79
+ });
80
+ });
81
+ });
@@ -162,7 +162,9 @@ export const initializeConfirm = () => {
162
162
  return handleDocumentEvent(ev, [
163
163
  Rails.linkClickSelector,
164
164
  Rails.buttonClickSelector,
165
- Rails.formInputClickSelector
165
+ Rails.formInputClickSelector,
166
+ 'button[data-confirm][type="button"]',
167
+ "form button[data-confirm]"
166
168
  ]);
167
169
  });
168
170
  document.addEventListener("change", (ev) => {
@@ -180,6 +182,11 @@ export const initializeConfirm = () => {
180
182
  $(Rails.formInputClickSelector).on("click.confirm", (ev) => {
181
183
  handleConfirm(ev, getMatchingEventTarget(ev, Rails.formInputClickSelector));
182
184
  });
185
+
186
+ // Handle button[type="button"] with data-confirm inside forms
187
+ $('button[data-confirm][type="button"]').on("click.confirm", (ev) => {
188
+ handleConfirm(ev, ev.currentTarget);
189
+ });
183
190
  });
184
191
  };
185
192