decidim-core 0.31.2 → 0.31.4

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 (101) 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/nav_links/show.erb +3 -3
  4. data/app/cells/decidim/participatory_space_private_user/show.erb +6 -6
  5. data/app/cells/decidim/participatory_space_private_user_cell.rb +0 -4
  6. data/app/cells/decidim/report_button/already_reported_modal.erb +1 -1
  7. data/app/cells/decidim/report_button/flag_modal.erb +1 -1
  8. data/app/cells/decidim/report_user_button/already_reported_modal.erb +1 -1
  9. data/app/cells/decidim/report_user_button/flag_modal.erb +1 -1
  10. data/app/cells/decidim/share_text_widget/modal.erb +1 -1
  11. data/app/cells/decidim/upload_modal/files.erb +5 -1
  12. data/app/cells/decidim/upload_modal_cell.rb +10 -1
  13. data/app/commands/decidim/multiple_attachments_methods.rb +20 -3
  14. data/app/helpers/decidim/mailer_helper.rb +36 -0
  15. data/app/helpers/decidim/menu_helper.rb +2 -1
  16. data/app/helpers/decidim/newsletters_helper.rb +4 -22
  17. data/app/jobs/decidim/find_and_update_descendants_job.rb +8 -2
  18. data/app/jobs/decidim/update_search_indexes_job.rb +2 -2
  19. data/app/mailers/decidim/application_mailer.rb +4 -0
  20. data/app/packs/src/decidim/a11y.js +29 -0
  21. data/app/packs/src/decidim/a11y.test.js +81 -0
  22. data/app/packs/src/decidim/confirm.js +8 -1
  23. data/app/packs/src/decidim/confirm.test.js +225 -0
  24. data/app/packs/src/decidim/controllers/accordion/accordion.test.js +118 -0
  25. data/app/packs/src/decidim/controllers/accordion/controller.js +24 -0
  26. data/app/packs/src/decidim/controllers/dropdown/controller.js +26 -0
  27. data/app/packs/src/decidim/controllers/dropdown/dropdown.test.js +187 -0
  28. data/app/packs/src/decidim/controllers/form_validator/form_validator.js +3 -2
  29. data/app/packs/src/decidim/controllers/form_validator/form_validator.test.js +5 -0
  30. data/app/packs/src/decidim/controllers/language_change/controller.js +1 -0
  31. data/app/packs/src/decidim/controllers/language_change/language_change.test.js +13 -0
  32. data/app/packs/src/decidim/datepicker/datepicker_functions.js +26 -0
  33. data/app/packs/src/decidim/datepicker/generate_datepicker.js +2 -1
  34. data/app/packs/src/decidim/datepicker/generate_timepicker.js +3 -2
  35. data/app/packs/src/decidim/datepicker/test/datepicker_functions_adjust_picker_position.test.js +234 -0
  36. data/app/packs/src/decidim/editor/extensions/image/index.js +49 -11
  37. data/app/packs/src/decidim/editor/extensions/image/node_view.js +9 -1
  38. data/app/packs/src/decidim/editor/extensions/link/bubble_menu.js +34 -6
  39. data/app/packs/src/decidim/editor/extensions/link/index.js +45 -12
  40. data/app/packs/src/decidim/editor/test/extensions/image_links.test.js +161 -0
  41. data/app/packs/src/decidim/refactor/moved/focus_guard.js +4 -4
  42. data/app/packs/stylesheets/decidim/_cards.scss +12 -4
  43. data/app/packs/stylesheets/decidim/_flash.scss +1 -1
  44. data/app/packs/stylesheets/decidim/_rich_text.scss +17 -0
  45. data/app/packs/stylesheets/decidim/editor.scss +10 -0
  46. data/app/presenters/decidim/menu_item_presenter.rb +7 -1
  47. data/app/views/decidim/devise/invitations/edit.html.erb +3 -3
  48. data/app/views/decidim/devise/registrations/new.html.erb +1 -0
  49. data/app/views/decidim/devise/shared/_tos_fields.html.erb +3 -3
  50. data/app/views/decidim/notification_mailer/event_received.html.erb +3 -3
  51. data/app/views/decidim/pages/_tabbed.html.erb +3 -3
  52. data/app/views/decidim/shared/_filters.html.erb +5 -5
  53. data/app/views/decidim/shared/filters/_check_boxes_tree.html.erb +1 -1
  54. data/app/views/decidim/shared/filters/_collection.html.erb +1 -1
  55. data/config/initializers/devise.rb +6 -0
  56. data/config/locales/ar.yml +3 -3
  57. data/config/locales/bg.yml +0 -4
  58. data/config/locales/ca-IT.yml +7 -6
  59. data/config/locales/ca.yml +7 -6
  60. data/config/locales/cs.yml +5 -8
  61. data/config/locales/de.yml +31 -8
  62. data/config/locales/el.yml +0 -2
  63. data/config/locales/en.yml +5 -4
  64. data/config/locales/es-MX.yml +10 -9
  65. data/config/locales/es-PY.yml +10 -9
  66. data/config/locales/es.yml +12 -11
  67. data/config/locales/eu.yml +7 -5
  68. data/config/locales/fi-plain.yml +10 -4
  69. data/config/locales/fi.yml +11 -5
  70. data/config/locales/fr-CA.yml +7 -5
  71. data/config/locales/fr.yml +8 -7
  72. data/config/locales/gl.yml +0 -2
  73. data/config/locales/hu.yml +4 -8
  74. data/config/locales/id-ID.yml +0 -2
  75. data/config/locales/it.yml +1 -3
  76. data/config/locales/ja.yml +7 -8
  77. data/config/locales/lb.yml +0 -2
  78. data/config/locales/lt.yml +1 -3
  79. data/config/locales/lv.yml +0 -2
  80. data/config/locales/nl.yml +0 -2
  81. data/config/locales/no.yml +0 -2
  82. data/config/locales/pl.yml +0 -4
  83. data/config/locales/pt-BR.yml +4 -5
  84. data/config/locales/pt.yml +0 -2
  85. data/config/locales/ro-RO.yml +1 -5
  86. data/config/locales/ru.yml +0 -2
  87. data/config/locales/sk.yml +0 -4
  88. data/config/locales/sv.yml +8 -7
  89. data/config/locales/tr-TR.yml +17 -5
  90. data/config/locales/zh-CN.yml +0 -2
  91. data/config/locales/zh-TW.yml +1 -3
  92. data/lib/decidim/assets/tailwind/tailwind.config.js.erb +1 -1
  93. data/lib/decidim/content_parsers/blob_parser.rb +3 -3
  94. data/lib/decidim/content_renderers/blob_renderer.rb +2 -2
  95. data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +121 -0
  96. data/lib/decidim/core/version.rb +1 -1
  97. data/lib/decidim/form_builder.rb +58 -36
  98. data/lib/decidim/maintenance/taxonomy_importer.rb +1 -1
  99. data/lib/decidim/participatory_space_user.rb +1 -1
  100. data/lib/decidim/searchable.rb +4 -4
  101. metadata +14 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d24b967768820d8eb01abd435e318b7a869af854b0f374ca54c54a6dddbdddb6
4
- data.tar.gz: f4374ee02ae0c5c9a580c8ad1c371c6552719be39b40e22cdf3d37018464950c
3
+ metadata.gz: 513dc9560cc17e12439bcef89efbb034edc6300b791810f2037bbb64191805a6
4
+ data.tar.gz: 99d7f076a772765592d34f5164659c5f4d0826edb4d146d5917fff28e02dc922
5
5
  SHA512:
6
- metadata.gz: a4baebaa527bd42897989aee6feeb29b4bf2773872b3bb7f8379a230111a94f0ee8565e15b02599fb6a1c20bb83a24feed96707448eecc70b866d74a8dd34bec
7
- data.tar.gz: 47436bb0abfd556afcebe781b153aaaa9e960653be07fdf19486f11d82bfc40b61472dc24cfbc397838b19638a395bbad3dc3af8e1d791b2277412b3b56e719d
6
+ metadata.gz: d8928571dc994667af951c63981ce2472ce1be5db0c477beb27cadfc2ac43b6cfa87f1a8ab6854483cd00ce46fb1856adacf593ad4e159e7e1a46dc3ae099b87
7
+ data.tar.gz: e03706f2aadaac2165c6a513a5b1284011bda181ab5a29683fe5983e616c880fd9c7a6ff15cbf1619fb6d6f658eb30df40e4d151cb81f12b0bdbd16c7848b224
@@ -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
@@ -1,12 +1,12 @@
1
1
  <div class="participatory-space__nav-container">
2
- <button id="dropdown-trigger-participatory-space" data-controller="dropdown" data-target="dropdown-menu-participatory-space" data-auto-close="true" data-scroll-to-menu="true">
2
+ <button id="dropdown-trigger-participatory-space" data-controller="dropdown" data-target="dropdown-menu-participatory-space" data-auto-close="true" data-scroll-to-menu="true" data-add-aria-roles="false" data-open-md="true">
3
3
  <span><%= t("decidim.searches.filters.jump_to") %></span>
4
4
  <%= icon "arrow-down-s-line" %>
5
5
  <%= icon "arrow-up-s-line" %>
6
6
  </button>
7
- <ul id="dropdown-menu-participatory-space" class="participatory-space__nav" aria-hidden="true">
7
+ <ul id="dropdown-menu-participatory-space" class="participatory-space__nav">
8
8
  <% model.each do |item| %>
9
- <li role="menuitem">
9
+ <li>
10
10
  <%= link_to item[:url], class: "participatory-space__nav-item" do %>
11
11
  <%= decidim_escape_translated(item[:name]) %>
12
12
  <%= icon "arrow-right-line" %>
@@ -1,13 +1,13 @@
1
- <div class="profile__user">
1
+ <%= link_to profile_url, class: "profile__user" do %>
2
2
  <div class="profile__user-avatar-container">
3
- <div class="<%= has_profile? ? "profile__user-avatar" : "profile__user-avatar !border-0" %>">
4
- <%= image_tag(has_profile? ? model.avatar_url(:big) : model.non_user_avatar_path, alt: "member-avatar") %>
3
+ <div class="profile__user-avatar">
4
+ <%= image_tag(model.avatar_url(:big), alt: "member-avatar") %>
5
5
  </div>
6
6
  </div>
7
7
  <div>
8
- <div class="<%= has_profile? ? "profile__user-name" : "profile__user-name !no-underline" %>">
8
+ <span class="profile__user-name">
9
9
  <%= name %>
10
- </div>
10
+ </span>
11
11
  <% if nickname.present? %>
12
12
  <span class="profile__user-nick block">
13
13
  <%= nickname %>
@@ -20,4 +20,4 @@
20
20
  </span>
21
21
  </div>
22
22
  </div>
23
- </div>
23
+ <% end %>
@@ -10,10 +10,6 @@ module Decidim
10
10
 
11
11
  private
12
12
 
13
- def has_profile?
14
- model.profile_url.present?
15
- end
16
-
17
13
  def role_translated
18
14
  decidim_html_escape(decidim_sanitize(translated_attribute(role)))
19
15
  end
@@ -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
  <%= decidim_form_for report_form, builder:, url: report_path, method: :post, html: { id: nil, data: { controller: "report-form" } } 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
  <%= decidim_form_for report_form, builder:, url: report_path, method: :post, html: { id: nil, data: { controller: "report-form" } } 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,6 +1,6 @@
1
1
  <%= decidim_modal id: "socialShare", class: "share-modal" do %>
2
2
  <div data-dialog-container>
3
- <h2 tabindex="-1" data-dialog-title><%= t("share", scope: "decidim.shared.share_modal") %></h2>
3
+ <h2 id="dialog-title-socialShare" tabindex="-1" data-dialog-title><%= t("share", scope: "decidim.shared.share_modal") %></h2>
4
4
 
5
5
  <div>
6
6
 
@@ -42,7 +42,11 @@
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 %>
@@ -128,7 +128,16 @@ module Decidim
128
128
  @attachments = begin
129
129
  attachments = options[:attachments] || form.object.send(attribute)
130
130
  attachments = Array(attachments).compact_blank
131
- 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
132
141
  end
133
142
  end
134
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 Decidim.storage_cdn_host.present?
18
+ Decidim.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
 
@@ -8,6 +8,13 @@ import Dialogs from "a11y-dialog-component";
8
8
  * @return {void}
9
9
  */
10
10
  const createDialog = (component) => {
11
+ const getFocusableElements = (container) => {
12
+ const selectors = "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1'])";
13
+ return Array.from(container.querySelectorAll(selectors)).filter(
14
+ (el) => el.offsetParent !== null
15
+ );
16
+ };
17
+
11
18
  const {
12
19
  dataset: { dialog, ...attrs }
13
20
  } = component;
@@ -29,11 +36,33 @@ const createDialog = (component) => {
29
36
  backdropSelector: `[data-dialog="${dialog}"]`,
30
37
  enableAutoFocus: false,
31
38
  onOpen: (params, trigger) => {
39
+ const keyHandler = (event) => {
40
+ if (event.key !== "Tab") {
41
+ return;
42
+ }
43
+ const focusable = getFocusableElements(params);
44
+ if (focusable.length === 0) {
45
+ return;
46
+ }
47
+ if (event.shiftKey && document.activeElement === focusable[0]) {
48
+ event.preventDefault();
49
+ focusable[focusable.length - 1].focus({ preventScroll: true });
50
+ } else if (!event.shiftKey && document.activeElement === focusable[focusable.length - 1]) {
51
+ event.preventDefault();
52
+ focusable[0].focus({ preventScroll: true });
53
+ }
54
+ };
55
+ params._focusTrapHandler = keyHandler;
56
+ params.addEventListener("keydown", keyHandler);
32
57
  setFocusOnTitle(params);
33
58
  window.focusGuard.trap(params, trigger);
34
59
  params.dispatchEvent(new CustomEvent("open.dialog"));
35
60
  },
36
61
  onClose: (params) => {
62
+ if (params._focusTrapHandler) {
63
+ params.removeEventListener("keydown", params._focusTrapHandler);
64
+ Reflect.deleteProperty(params, "_focusTrapHandler");
65
+ }
37
66
  window.focusGuard.disable();
38
67
  params.dispatchEvent(new CustomEvent("close.dialog"));
39
68
  },
@@ -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
+ });
@@ -169,7 +169,9 @@ export const initializeConfirm = () => {
169
169
  return handleDocumentEvent(ev, [
170
170
  Rails.linkClickSelector,
171
171
  Rails.buttonClickSelector,
172
- Rails.formInputClickSelector
172
+ Rails.formInputClickSelector,
173
+ 'button[data-confirm][type="button"]',
174
+ "form button[data-confirm]"
173
175
  ]);
174
176
  });
175
177
  document.addEventListener("change", (ev) => {
@@ -187,6 +189,11 @@ export const initializeConfirm = () => {
187
189
  $(Rails.formInputClickSelector).on("click.confirm", (ev) => {
188
190
  handleConfirm(ev, getMatchingEventTarget(ev, Rails.formInputClickSelector));
189
191
  });
192
+
193
+ // Handle button[type="button"] with data-confirm inside forms
194
+ $('button[data-confirm][type="button"]').on("click.confirm", (ev) => {
195
+ handleConfirm(ev, ev.currentTarget);
196
+ });
190
197
  });
191
198
  };
192
199