decidim-core 0.30.6 → 0.30.7

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 (66) 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/jobs/decidim/find_and_update_descendants_job.rb +8 -2
  13. data/app/jobs/decidim/update_search_indexes_job.rb +2 -2
  14. data/app/packs/src/decidim/a11y.js +29 -0
  15. data/app/packs/src/decidim/a11y.test.js +81 -0
  16. data/app/packs/src/decidim/confirm.js +8 -1
  17. data/app/packs/src/decidim/confirm.test.js +225 -0
  18. data/app/packs/src/decidim/direct_uploads/upload_field.js +1 -1
  19. data/app/packs/src/decidim/focus_guard.js +4 -4
  20. data/app/packs/stylesheets/decidim/_cards.scss +12 -4
  21. data/app/packs/stylesheets/decidim/_flash.scss +1 -1
  22. data/app/packs/stylesheets/decidim/_modal_update.scss +1 -1
  23. data/app/views/decidim/devise/invitations/edit.html.erb +3 -3
  24. data/config/initializers/devise.rb +6 -0
  25. data/config/locales/ar.yml +3 -3
  26. data/config/locales/bg.yml +0 -4
  27. data/config/locales/ca-IT.yml +7 -6
  28. data/config/locales/ca.yml +7 -6
  29. data/config/locales/cs.yml +5 -8
  30. data/config/locales/de.yml +4 -8
  31. data/config/locales/el.yml +0 -2
  32. data/config/locales/en.yml +5 -4
  33. data/config/locales/es-MX.yml +8 -7
  34. data/config/locales/es-PY.yml +8 -7
  35. data/config/locales/es.yml +10 -9
  36. data/config/locales/eu.yml +5 -6
  37. data/config/locales/fi-plain.yml +5 -4
  38. data/config/locales/fi.yml +6 -5
  39. data/config/locales/fr-CA.yml +7 -5
  40. data/config/locales/fr.yml +8 -7
  41. data/config/locales/gl.yml +0 -2
  42. data/config/locales/hu.yml +5 -9
  43. data/config/locales/id-ID.yml +0 -2
  44. data/config/locales/it.yml +1 -3
  45. data/config/locales/ja.yml +7 -8
  46. data/config/locales/lb.yml +0 -2
  47. data/config/locales/lt.yml +1 -3
  48. data/config/locales/lv.yml +0 -2
  49. data/config/locales/nl.yml +0 -2
  50. data/config/locales/no.yml +0 -2
  51. data/config/locales/pl.yml +0 -4
  52. data/config/locales/pt-BR.yml +4 -5
  53. data/config/locales/pt.yml +0 -2
  54. data/config/locales/ro-RO.yml +1 -5
  55. data/config/locales/ru.yml +0 -2
  56. data/config/locales/sk.yml +0 -4
  57. data/config/locales/sv.yml +8 -7
  58. data/config/locales/tr-TR.yml +17 -5
  59. data/config/locales/zh-CN.yml +0 -2
  60. data/config/locales/zh-TW.yml +1 -3
  61. data/lib/decidim/assets/tailwind/tailwind.config.js.erb +1 -1
  62. data/lib/decidim/core/version.rb +1 -1
  63. data/lib/decidim/form_builder.rb +25 -1
  64. data/lib/decidim/maintenance/taxonomy_importer.rb +1 -1
  65. data/lib/decidim/searchable.rb +4 -4
  66. metadata +8 -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: 5110ff22ed67aef95bf387023ec7d18d9602a92944c82c2ab9373e786c04393c
4
+ data.tar.gz: b754a2b2ddb4a72ae07dcd384203d61454ced7f6a3a41a4f80a5bb501b3dc310
5
5
  SHA512:
6
- metadata.gz: eb126980873b650eaa4c89d208bbd0d266df4da01196f60621211e896501e615329f1e35e34251c66f8e7f67b71d4df6cfaefe38eb4343f69d7d65437b62f51e
7
- data.tar.gz: 3204e02c1097ee49b967bfd558fd51b32f3faa6d3ee9352590e6c98c0020c86590c383756b0b5694c97fe3e4b6689fcbecb422e15b2bae929656533a5908af9b
6
+ metadata.gz: 3659a2a34d2531735e1a03710329261952c724361d0661bb9cf02c7a2acc76c2516266e8030a82e4dcc40789361d67845f1421addfcbf0f1fe4e0743a1288572
7
+ data.tar.gz: 9ec948751168cd3f4a8cc2b768be6a118cd22b47aecb0712fa64a1c01d58a5cdf2b7f2f20963fdd0eec2384a41753bbaec558016f533bfab7176227df6f3cadd
@@ -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
@@ -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
@@ -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
 
@@ -0,0 +1,225 @@
1
+ /* global jest */
2
+
3
+ jest.mock("src/decidim/icon", () => () => "<svg></svg>");
4
+
5
+ describe("Confirm dialog for button[type='button']", () => {
6
+ let mockRails = null;
7
+ let mockDecidim = null;
8
+
9
+ beforeEach(() => {
10
+ jest.clearAllMocks();
11
+ document.body.innerHTML = `
12
+ <div id="confirm-modal" style="display: none;">
13
+ <div data-dialog-title></div>
14
+ <div class="confirm-modal-icon"></div>
15
+ <div data-confirm-modal-content></div>
16
+ <button data-confirm-ok>Confirm</button>
17
+ <button data-confirm-cancel>Cancel</button>
18
+ </div>
19
+ `;
20
+
21
+ mockRails = {
22
+ linkClickSelector: "a[data-confirm]",
23
+ buttonClickSelector: "button[data-confirm]:not([form])",
24
+ formInputClickSelector: 'form button[type="submit"], form button:not([type])',
25
+ inputChangeSelector: "input[data-confirm], select[data-confirm]",
26
+ formSubmitSelector: "form[data-confirm]",
27
+ stopEverything: jest.fn(),
28
+ fire: jest.fn((el, event) => {
29
+ const evt = new CustomEvent(event);
30
+ el.dispatchEvent(evt);
31
+ return true;
32
+ }),
33
+ matches: function(element, selector) {
34
+ if (element instanceof Element) {
35
+ return element.matches(selector);
36
+ }
37
+ return false;
38
+ }
39
+ };
40
+
41
+ mockDecidim = {
42
+ currentDialogs: {
43
+ "confirm-modal": {
44
+ open: jest.fn(),
45
+ close: jest.fn()
46
+ }
47
+ }
48
+ };
49
+
50
+ window.Rails = mockRails;
51
+ window.Decidim = mockDecidim;
52
+ });
53
+
54
+ afterEach(() => {
55
+ document.body.innerHTML = "";
56
+ });
57
+
58
+ describe("selector matching for button[type='button'] with data-confirm", () => {
59
+ it("matches button[data-confirm][type='button'] selector", () => {
60
+ const button = document.createElement("button");
61
+ button.type = "button";
62
+ button.setAttribute("data-confirm", "Are you sure?");
63
+
64
+ expect(button.matches('button[data-confirm][type="button"]')).toBe(true);
65
+ });
66
+
67
+ it("matches form button[data-confirm] selector", () => {
68
+ const form = document.createElement("form");
69
+ const button = document.createElement("button");
70
+ button.setAttribute("data-confirm", "Are you sure?");
71
+ form.appendChild(button);
72
+
73
+ expect(button.matches("form button[data-confirm]")).toBe(true);
74
+ });
75
+
76
+ it("does not match regular button without data-confirm", () => {
77
+ const button = document.createElement("button");
78
+ button.type = "button";
79
+
80
+ expect(button.matches('button[data-confirm][type="button"]')).toBe(false);
81
+ });
82
+
83
+ it("does not match button[type='submit'] with the type='button' selector", () => {
84
+ const button = document.createElement("button");
85
+ button.type = "submit";
86
+ button.setAttribute("data-confirm", "Are you sure?");
87
+
88
+ expect(button.matches('button[data-confirm][type="button"]')).toBe(false);
89
+ });
90
+
91
+ it("matches button[type='button'] inside form", () => {
92
+ document.body.innerHTML = `
93
+ <form>
94
+ <button type="button" data-confirm="Test message">Click me</button>
95
+ </form>
96
+ `;
97
+
98
+ const button = document.querySelector('button[type="button"]');
99
+ expect(button.matches('button[data-confirm][type="button"]')).toBe(true);
100
+ expect(button.matches("form button[data-confirm]")).toBe(true);
101
+ });
102
+
103
+ it("does not match button[type='button'] without data-confirm inside form", () => {
104
+ document.body.innerHTML = `
105
+ <form>
106
+ <button type="button">Click me</button>
107
+ </form>
108
+ `;
109
+
110
+ const button = document.querySelector('button[type="button"]');
111
+ expect(button.matches('button[data-confirm][type="button"]')).toBe(false);
112
+ expect(button.matches("form button[data-confirm]")).toBe(false);
113
+ });
114
+ });
115
+
116
+ describe("initializeConfirm - selectors registration", () => {
117
+ it("adds click event listener with proper selectors including button[type='button'] support", async () => {
118
+ const { initializeConfirm } = await import("src/decidim/confirm.js");
119
+
120
+ const addEventListenerSpy = jest.spyOn(document, "addEventListener");
121
+
122
+ initializeConfirm();
123
+
124
+ expect(addEventListenerSpy).toHaveBeenCalledWith("click", expect.any(Function));
125
+ });
126
+
127
+ it("adds change event listener for input change selector", async () => {
128
+ const { initializeConfirm } = await import("src/decidim/confirm.js");
129
+
130
+ const addEventListenerSpy = jest.spyOn(document, "addEventListener");
131
+
132
+ initializeConfirm();
133
+
134
+ const changeHandlerCalls = addEventListenerSpy.mock.calls.filter(
135
+ (call) => call[0] === "change"
136
+ );
137
+ expect(changeHandlerCalls.length).toBeGreaterThan(0);
138
+ });
139
+
140
+ it("adds submit event listener for form submit selector", async () => {
141
+ const { initializeConfirm } = await import("src/decidim/confirm.js");
142
+
143
+ const addEventListenerSpy = jest.spyOn(document, "addEventListener");
144
+
145
+ initializeConfirm();
146
+
147
+ const submitHandlerCalls = addEventListenerSpy.mock.calls.filter(
148
+ (call) => call[0] === "submit"
149
+ );
150
+ expect(submitHandlerCalls.length).toBeGreaterThan(0);
151
+ });
152
+
153
+ it("adds DOMContentLoaded event listener for Foundation Abide compatibility", async () => {
154
+ const { initializeConfirm } = await import("src/decidim/confirm.js");
155
+
156
+ const addEventListenerSpy = jest.spyOn(document, "addEventListener");
157
+
158
+ initializeConfirm();
159
+
160
+ const turboLoadCalls = addEventListenerSpy.mock.calls.filter(
161
+ (call) => call[0] === "DOMContentLoaded"
162
+ );
163
+ expect(turboLoadCalls.length).toBeGreaterThan(0);
164
+ });
165
+ });
166
+
167
+ describe("handleDocumentEvent with button[type='button'] support", () => {
168
+ it("handles click on button[type='button'] with data-confirm and form attribute", async () => {
169
+ const { initializeConfirm } = await import("src/decidim/confirm.js");
170
+
171
+ document.body.innerHTML = `
172
+ <form id="my-form">
173
+ <button type="button" form="my-form" data-confirm="Are you sure?">Click me</button>
174
+ </form>
175
+ `;
176
+
177
+ const button = document.querySelector('button[type="button"]');
178
+ const openSpy = jest.spyOn(mockDecidim.currentDialogs["confirm-modal"], "open");
179
+
180
+ initializeConfirm();
181
+
182
+ button.click();
183
+
184
+ expect(openSpy).toHaveBeenCalled();
185
+ });
186
+
187
+ it("handles click on button[type='button'] with data-confirm outside form", async () => {
188
+ const { initializeConfirm } = await import("src/decidim/confirm.js");
189
+
190
+ document.body.innerHTML = `
191
+ <button type="button" data-confirm="Are you sure?">Click me</button>
192
+ `;
193
+
194
+ const button = document.querySelector('button[type="button"]');
195
+ const openSpy = jest.spyOn(mockDecidim.currentDialogs["confirm-modal"], "open");
196
+
197
+ initializeConfirm();
198
+
199
+ button.click();
200
+
201
+ expect(openSpy).toHaveBeenCalled();
202
+ });
203
+
204
+ it("does not trigger confirm for button without data-confirm attribute", async () => {
205
+ const { initializeConfirm } = await import("src/decidim/confirm.js");
206
+
207
+ document.body.innerHTML = `
208
+ <form>
209
+ <button type="button">Click me</button>
210
+ </form>
211
+ `;
212
+
213
+ const button = document.querySelector('button[type="button"]');
214
+ const openSpy = jest.spyOn(mockDecidim.currentDialogs["confirm-modal"], "open");
215
+
216
+ initializeConfirm();
217
+
218
+ button.click();
219
+
220
+ expect(openSpy).not.toHaveBeenCalled();
221
+ });
222
+ });
223
+ });
224
+
225
+ /* dummy end */
@@ -79,7 +79,7 @@ const updateActiveUploads = (modal) => {
79
79
  const template = `
80
80
  <div ${attachmentIdOrHiddenField} data-filename="${escapeQuotes(file.name)}" data-title="${escapeQuotes(title)}">
81
81
  ${(/image/).test(file.type) && "<div><img src=\"data:,\" role=\"presentation\" /></div>" || ""}
82
- <span>${escapeHtml(title)}</span>
82
+ <p>${escapeHtml(title)}</p>
83
83
  ${hidden}
84
84
  </div>
85
85
  `
@@ -78,16 +78,16 @@ export default class FocusGuard {
78
78
 
79
79
  let target = null;
80
80
  if (guard.dataset.position === "start") {
81
- // Focus at the start guard, so focus the first focusable element after that
82
- for (let ind = 0; ind < visibleNodes.length; ind += 1) {
81
+ // Focus at the start guard, so focus the last focusable element (cycle forward to end)
82
+ for (let ind = visibleNodes.length - 1; ind >= 0; ind -= 1) {
83
83
  if (!this.isFocusGuard(visibleNodes[ind]) && this.isFocusable(visibleNodes[ind])) {
84
84
  target = visibleNodes[ind];
85
85
  break;
86
86
  }
87
87
  }
88
88
  } else {
89
- // Focus at the end guard, so focus the first focusable element after that
90
- for (let ind = visibleNodes.length - 1; ind >= 0; ind -= 1) {
89
+ // Focus at the end guard, so focus the first focusable element (cycle back to start)
90
+ for (let ind = 0; ind < visibleNodes.length; ind += 1) {
91
91
  if (!this.isFocusGuard(visibleNodes[ind]) && this.isFocusable(visibleNodes[ind])) {
92
92
  target = visibleNodes[ind];
93
93
  break;