decidim-core 0.31.2 → 0.31.3

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 (69) 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/report_button/already_reported_modal.erb +1 -1
  4. data/app/cells/decidim/report_button/flag_modal.erb +1 -1
  5. data/app/cells/decidim/report_user_button/already_reported_modal.erb +1 -1
  6. data/app/cells/decidim/report_user_button/flag_modal.erb +1 -1
  7. data/app/cells/decidim/share_text_widget/modal.erb +1 -1
  8. data/app/cells/decidim/upload_modal/files.erb +5 -1
  9. data/app/cells/decidim/upload_modal_cell.rb +10 -1
  10. data/app/commands/decidim/multiple_attachments_methods.rb +20 -3
  11. data/app/jobs/decidim/find_and_update_descendants_job.rb +8 -2
  12. data/app/jobs/decidim/update_search_indexes_job.rb +2 -2
  13. data/app/packs/src/decidim/a11y.js +29 -0
  14. data/app/packs/src/decidim/a11y.test.js +81 -0
  15. data/app/packs/src/decidim/confirm.js +8 -1
  16. data/app/packs/src/decidim/confirm.test.js +225 -0
  17. data/app/packs/src/decidim/controllers/language_change/controller.js +1 -0
  18. data/app/packs/src/decidim/controllers/language_change/language_change.test.js +13 -0
  19. data/app/packs/src/decidim/datepicker/datepicker_functions.js +26 -0
  20. data/app/packs/src/decidim/datepicker/generate_datepicker.js +2 -1
  21. data/app/packs/src/decidim/datepicker/generate_timepicker.js +3 -2
  22. data/app/packs/src/decidim/datepicker/test/datepicker_functions_adjust_picker_position.test.js +234 -0
  23. data/app/packs/src/decidim/refactor/moved/focus_guard.js +4 -4
  24. data/app/packs/stylesheets/decidim/_cards.scss +12 -4
  25. data/app/packs/stylesheets/decidim/_flash.scss +1 -1
  26. data/app/views/decidim/devise/invitations/edit.html.erb +3 -3
  27. data/config/initializers/devise.rb +6 -0
  28. data/config/locales/ar.yml +3 -3
  29. data/config/locales/bg.yml +0 -4
  30. data/config/locales/ca-IT.yml +7 -6
  31. data/config/locales/ca.yml +7 -6
  32. data/config/locales/cs.yml +5 -8
  33. data/config/locales/de.yml +4 -8
  34. data/config/locales/el.yml +0 -2
  35. data/config/locales/en.yml +5 -4
  36. data/config/locales/es-MX.yml +10 -9
  37. data/config/locales/es-PY.yml +10 -9
  38. data/config/locales/es.yml +12 -11
  39. data/config/locales/eu.yml +4 -5
  40. data/config/locales/fi-plain.yml +5 -4
  41. data/config/locales/fi.yml +6 -5
  42. data/config/locales/fr-CA.yml +7 -5
  43. data/config/locales/fr.yml +8 -7
  44. data/config/locales/gl.yml +0 -2
  45. data/config/locales/hu.yml +4 -8
  46. data/config/locales/id-ID.yml +0 -2
  47. data/config/locales/it.yml +1 -3
  48. data/config/locales/ja.yml +7 -8
  49. data/config/locales/lb.yml +0 -2
  50. data/config/locales/lt.yml +1 -3
  51. data/config/locales/lv.yml +0 -2
  52. data/config/locales/nl.yml +0 -2
  53. data/config/locales/no.yml +0 -2
  54. data/config/locales/pl.yml +0 -4
  55. data/config/locales/pt-BR.yml +4 -5
  56. data/config/locales/pt.yml +0 -2
  57. data/config/locales/ro-RO.yml +1 -5
  58. data/config/locales/ru.yml +0 -2
  59. data/config/locales/sk.yml +0 -4
  60. data/config/locales/sv.yml +8 -7
  61. data/config/locales/tr-TR.yml +17 -5
  62. data/config/locales/zh-CN.yml +0 -2
  63. data/config/locales/zh-TW.yml +1 -3
  64. data/lib/decidim/assets/tailwind/tailwind.config.js.erb +1 -1
  65. data/lib/decidim/core/version.rb +1 -1
  66. data/lib/decidim/form_builder.rb +58 -36
  67. data/lib/decidim/maintenance/taxonomy_importer.rb +1 -1
  68. data/lib/decidim/searchable.rb +4 -4
  69. metadata +9 -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: c44cbb210028e54b7a2bcc3a10fc79e041e355eac55e81b9a4b934f5fb5b42db
4
+ data.tar.gz: 0d87937801924b8d92ef31e2b136019090118d89b46ddbe5f2ce4052eef207b0
5
5
  SHA512:
6
- metadata.gz: a4baebaa527bd42897989aee6feeb29b4bf2773872b3bb7f8379a230111a94f0ee8565e15b02599fb6a1c20bb83a24feed96707448eecc70b866d74a8dd34bec
7
- data.tar.gz: 47436bb0abfd556afcebe781b153aaaa9e960653be07fdf19486f11d82bfc40b61472dc24cfbc397838b19638a395bbad3dc3af8e1d791b2277412b3b56e719d
6
+ metadata.gz: 508ad856daf2345e66f3c6b0b01c1a0c851d225eca8d0e4e739d79f5584dacb6de8c0155b8a3f28ac80961f6346c31ef3d6fdc14f80299fd80184ea64697b4ef
7
+ data.tar.gz: 931b27cfcafe94a6f922d26c2c419dbf89386f64147234d5cf129641b52e2651e877cbfff63ec13f9844c5e6f91c12730df30f0fea2a5dffe2f6c61551bee522
@@ -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,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
@@ -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
@@ -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
 
@@ -0,0 +1,225 @@
1
+ /* global jest */
2
+
3
+ jest.mock("src/decidim/refactor/moved/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 turbo:load 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] === "turbo:load"
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 */
@@ -10,6 +10,7 @@ export default class extends Controller {
10
10
  connect() {
11
11
  this.handleChange = this.handleChange.bind(this);
12
12
  this.element.addEventListener("change", this.handleChange);
13
+ this.element.dispatchEvent(new Event("change"));
13
14
  }
14
15
 
15
16
  disconnect() {
@@ -72,6 +72,19 @@ describe("LanguageChangeController", () => {
72
72
  expect(removeSpy).toHaveBeenCalledWith("change", controller.handleChange);
73
73
  removeSpy.mockRestore();
74
74
  });
75
+
76
+ it("activates the selected option's panel on connect", () => {
77
+ const options = selectElement.querySelectorAll("option");
78
+ options[1].selected = true;
79
+
80
+ controller.disconnect();
81
+ controller.connect();
82
+
83
+ expect(panel0.classList.contains("is-active")).toBe(false);
84
+ expect(panel0.ariaHidden).toBe("true");
85
+ expect(panel1.classList.contains("is-active")).toBe(true);
86
+ expect(panel1.ariaHidden).toBe("false");
87
+ });
75
88
  });
76
89
 
77
90
  describe("handleChange", () => {
@@ -1,5 +1,31 @@
1
1
  // Utility helper functions for the date and time picker functionality
2
2
 
3
+ export const adjustPickerPosition = (input, datePickerContainer, selector) => {
4
+ const parent = input.closest(selector);
5
+
6
+ if (getComputedStyle(parent).position === "static") {
7
+ parent.style.position = "relative";
8
+ }
9
+
10
+ const rect = input.getBoundingClientRect();
11
+ const calendarHeight = datePickerContainer.offsetHeight;
12
+ const spaceAbove = rect.top;
13
+ const spaceBelow = window.innerHeight - rect.bottom;
14
+ const openBelow = spaceBelow >= calendarHeight || spaceBelow >= spaceAbove;
15
+
16
+ if (openBelow) {
17
+ // Open below
18
+ datePickerContainer.style.top = `${input.offsetHeight}px`;
19
+ datePickerContainer.style.bottom = "";
20
+ } else {
21
+ // Open above
22
+ datePickerContainer.style.top = "";
23
+ datePickerContainer.style.bottom = `${input.offsetHeight}px`;
24
+ }
25
+
26
+ datePickerContainer.style.right = "0px";
27
+ };
28
+
3
29
  export const setHour = (value, format) => {
4
30
  const hour = value.split(":")[0];
5
31
  if (format === 12) {
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable require-jsdoc */
2
2
  import icon from "src/decidim/refactor/moved/icon"
3
- import { dateToPicker, formatDate, displayDate, formatTime, calculateDatepickerPos } from "src/decidim/datepicker/datepicker_functions"
3
+ import { dateToPicker, formatDate, displayDate, formatTime, calculateDatepickerPos, adjustPickerPosition } from "src/decidim/datepicker/datepicker_functions"
4
4
  import { dateKeyDownListener, dateBeforeInputListener } from "src/decidim/datepicker/datepicker_listeners"
5
5
  import { getDictionary } from "src/decidim/refactor/moved/i18n"
6
6
 
@@ -136,6 +136,7 @@ export default function generateDatePicker(input, row, formats) {
136
136
  };
137
137
  pickedDate = null;
138
138
  datePickerContainer.style.display = "block";
139
+ adjustPickerPosition(date, datePickerContainer, ".datepicker__date-column");
139
140
 
140
141
  document.addEventListener("click", datePickerDisplay);
141
142