decidim-core 0.31.6 → 0.31.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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/card_metadata_cell.rb +1 -1
  3. data/app/cells/decidim/comments_button_cell.rb +9 -1
  4. data/app/cells/decidim/content_blocks/participatory_space_main_data/title.erb +1 -14
  5. data/app/cells/decidim/data_consent/category.erb +20 -33
  6. data/app/cells/decidim/following_cell.rb +1 -1
  7. data/app/cells/decidim/footer_topics/show.erb +5 -8
  8. data/app/cells/decidim/nav_links/show.erb +3 -1
  9. data/app/cells/decidim/participatory_space_dropdown_metadata/links.erb +1 -1
  10. data/app/cells/decidim/participatory_space_private_user/show.erb +1 -1
  11. data/app/cells/decidim/share_text_widget/modal.erb +1 -0
  12. data/app/cells/decidim/share_widget/modal.erb +1 -0
  13. data/app/cells/decidim/share_widget/qr_code_modal.erb +2 -4
  14. data/app/commands/decidim/create_omniauth_registration.rb +17 -2
  15. data/app/controllers/concerns/decidim/devise_authentication_methods.rb +4 -0
  16. data/app/controllers/concerns/decidim/devise_controllers.rb +2 -0
  17. data/app/controllers/concerns/decidim/disable_redirection_to_external_host.rb +2 -0
  18. data/app/controllers/concerns/decidim/filter_resource.rb +2 -0
  19. data/app/controllers/concerns/decidim/impersonate_users.rb +2 -2
  20. data/app/controllers/concerns/decidim/locale_switcher.rb +62 -11
  21. data/app/controllers/concerns/decidim/needs_permission.rb +22 -10
  22. data/app/controllers/concerns/decidim/onboarding_action_methods.rb +2 -0
  23. data/app/controllers/concerns/decidim/paginable.rb +7 -3
  24. data/app/controllers/concerns/decidim/payload_info.rb +2 -0
  25. data/app/controllers/concerns/decidim/resource_versions_concern.rb +14 -0
  26. data/app/controllers/decidim/application_controller.rb +0 -6
  27. data/app/controllers/decidim/authorization_modals_controller.rb +2 -0
  28. data/app/controllers/decidim/components/base_controller.rb +2 -0
  29. data/app/controllers/decidim/devise/passwords_controller.rb +8 -0
  30. data/app/controllers/decidim/favicon_controller.rb +9 -0
  31. data/app/controllers/decidim/free_resource_authorization_modals_controller.rb +2 -0
  32. data/app/controllers/decidim/gamification/badges_controller.rb +2 -0
  33. data/app/controllers/decidim/homepage_controller.rb +4 -1
  34. data/app/controllers/decidim/likes_controller.rb +1 -1
  35. data/app/controllers/decidim/links_controller.rb +2 -0
  36. data/app/controllers/decidim/manifests_controller.rb +2 -0
  37. data/app/controllers/decidim/messaging/conversations_controller.rb +10 -4
  38. data/app/controllers/decidim/newsletters_controller.rb +4 -0
  39. data/app/controllers/decidim/notifications_subscriptions_controller.rb +4 -0
  40. data/app/controllers/decidim/offline_controller.rb +5 -1
  41. data/app/controllers/decidim/open_data_controller.rb +5 -1
  42. data/app/controllers/decidim/pages_controller.rb +31 -3
  43. data/app/controllers/decidim/private_downloads_controller.rb +2 -0
  44. data/app/controllers/decidim/profiles_controller.rb +10 -0
  45. data/app/controllers/decidim/qr_controller.rb +3 -1
  46. data/app/controllers/decidim/resource_autocomplete_controller.rb +2 -0
  47. data/app/controllers/decidim/searches_controller.rb +2 -0
  48. data/app/controllers/decidim/short_links_controller.rb +4 -0
  49. data/app/controllers/decidim/static_map_controller.rb +2 -0
  50. data/app/controllers/decidim/timeouts_controller.rb +4 -0
  51. data/app/controllers/decidim/tos_controller.rb +2 -0
  52. data/app/controllers/decidim/upload_validations_controller.rb +4 -2
  53. data/app/controllers/decidim/user_activities_controller.rb +2 -0
  54. data/app/events/decidim/amendable/amendment_base_event.rb +7 -1
  55. data/app/forms/decidim/upload_validation_form.rb +16 -10
  56. data/app/helpers/concerns/decidim/user_role_checker.rb +35 -10
  57. data/app/helpers/decidim/menu_helper.rb +0 -2
  58. data/app/helpers/decidim/sanitize_helper.rb +11 -1
  59. data/app/jobs/decidim/application_job.rb +4 -0
  60. data/app/models/decidim/authorization.rb +24 -0
  61. data/app/models/decidim/oauth_application.rb +1 -0
  62. data/app/models/decidim/user.rb +3 -2
  63. data/app/models/decidim/user_base_entity.rb +1 -1
  64. data/app/packs/src/decidim/callout.js +10 -0
  65. data/app/packs/src/decidim/callout.test.js +32 -0
  66. data/app/packs/src/decidim/controllers/accordion/accordion.test.js +260 -42
  67. data/app/packs/src/decidim/controllers/accordion/controller.js +136 -17
  68. data/app/packs/src/decidim/controllers/dropdown/controller.js +6 -0
  69. data/app/packs/src/decidim/controllers/dropdown/dropdown.test.js +28 -0
  70. data/app/packs/src/decidim/controllers/password_toggler/controller.js +22 -1
  71. data/app/packs/src/decidim/controllers/password_toggler/password_toggler.test.js +11 -20
  72. data/app/packs/src/decidim/datepicker/datepicker_functions.js +34 -23
  73. data/app/packs/src/decidim/datepicker/generate_datepicker.js +4 -4
  74. data/app/packs/src/decidim/datepicker/test/time.test.js +39 -24
  75. data/app/packs/src/decidim/direct_uploads/upload_modal.js +2 -1
  76. data/app/packs/src/decidim/map/controller.js +10 -1
  77. data/app/packs/src/decidim/refactor/moved/focus_guard.js +12 -12
  78. data/app/packs/src/decidim/refactor/moved/focus_guard.test.js +136 -0
  79. data/app/packs/src/decidim/utilities/dom.js +2 -2
  80. data/app/packs/stylesheets/decidim/_cards.scss +1 -1
  81. data/app/packs/stylesheets/decidim/_content_blocks.scss +4 -10
  82. data/app/packs/stylesheets/decidim/_conversations.scss +0 -8
  83. data/app/packs/stylesheets/decidim/_cookies.scss +13 -5
  84. data/app/packs/stylesheets/decidim/_documents.scss +1 -1
  85. data/app/packs/stylesheets/decidim/_footer.scss +33 -1
  86. data/app/packs/stylesheets/decidim/_modal.scss +16 -8
  87. data/app/packs/stylesheets/decidim/_modal_share.scss +0 -22
  88. data/app/packs/stylesheets/decidim/_participatory_spaces.scss +12 -2
  89. data/app/packs/stylesheets/decidim/_rich_text.scss +12 -9
  90. data/app/packs/stylesheets/decidim/_typography.scss +5 -2
  91. data/app/packs/stylesheets/decidim/legacy/email.scss +0 -5
  92. data/app/permissions/decidim/permissions.rb +20 -0
  93. data/app/presenters/decidim/admin_log/component_presenter.rb +1 -1
  94. data/app/presenters/decidim/footer_menu_presenter.rb +2 -4
  95. data/app/presenters/decidim/log/base_presenter.rb +10 -1
  96. data/app/presenters/decidim/log/user_presenter.rb +2 -2
  97. data/app/resolvers/decidim/meta_image_url_resolver.rb +7 -0
  98. data/app/serializers/decidim/exporters/open_data_blocked_user_serializer.rb +11 -2
  99. data/app/serializers/decidim/exporters/open_data_moderation_serializer.rb +25 -1
  100. data/app/services/decidim/svg_content_validator.rb +554 -0
  101. data/app/uploaders/decidim/application_uploader.rb +2 -2
  102. data/app/validators/uploader_image_content_validator.rb +278 -0
  103. data/app/views/decidim/application/_radio_accordion.html.erb +1 -5
  104. data/app/views/decidim/messaging/conversations/create.js.erb +1 -3
  105. data/app/views/decidim/searches/_filters.html.erb +1 -1
  106. data/app/views/decidim/shared/_login_modal.html.erb +12 -14
  107. data/app/views/layouts/decidim/_mailer_logo.html.erb +1 -1
  108. data/app/views/layouts/decidim/footer/_main.html.erb +1 -1
  109. data/app/views/layouts/decidim/footer/_main_links.html.erb +56 -28
  110. data/config/initializers/rack_attack.rb +10 -0
  111. data/config/locales/ar.yml +0 -20
  112. data/config/locales/bg.yml +0 -20
  113. data/config/locales/ca-IT.yml +31 -24
  114. data/config/locales/ca.yml +31 -24
  115. data/config/locales/cs.yml +20 -22
  116. data/config/locales/de.yml +59 -26
  117. data/config/locales/el.yml +0 -18
  118. data/config/locales/en.yml +31 -24
  119. data/config/locales/es-MX.yml +31 -24
  120. data/config/locales/es-PY.yml +31 -24
  121. data/config/locales/es.yml +31 -24
  122. data/config/locales/eu.yml +178 -176
  123. data/config/locales/fi-plain.yml +29 -24
  124. data/config/locales/fi.yml +29 -24
  125. data/config/locales/fr-CA.yml +10 -20
  126. data/config/locales/fr.yml +10 -20
  127. data/config/locales/gl.yml +1440 -51
  128. data/config/locales/hu.yml +0 -18
  129. data/config/locales/id-ID.yml +0 -18
  130. data/config/locales/is-IS.yml +0 -6
  131. data/config/locales/it.yml +0 -17
  132. data/config/locales/ja.yml +29 -24
  133. data/config/locales/lb.yml +0 -18
  134. data/config/locales/lt.yml +0 -19
  135. data/config/locales/lv.yml +0 -18
  136. data/config/locales/nl.yml +0 -18
  137. data/config/locales/no.yml +0 -18
  138. data/config/locales/pl.yml +2 -20
  139. data/config/locales/pt-BR.yml +0 -26
  140. data/config/locales/pt.yml +0 -18
  141. data/config/locales/ro-RO.yml +0 -19
  142. data/config/locales/ru.yml +0 -8
  143. data/config/locales/sk.yml +2 -26
  144. data/config/locales/sv.yml +45 -29
  145. data/config/locales/tr-TR.yml +0 -20
  146. data/config/locales/uk.yml +0 -6
  147. data/config/locales/zh-CN.yml +0 -16
  148. data/config/locales/zh-TW.yml +0 -19
  149. data/db/migrate/20250217192438_convert_user_groups_into_users.rb +3 -2
  150. data/db/migrate/20260909000000_add_failed_attempts_and_locked_at_to_decidim_authorizations.rb +8 -0
  151. data/decidim-core.gemspec +3 -0
  152. data/lib/decidim/core/test/shared_examples/amendable/amendment_event_examples.rb +2 -2
  153. data/lib/decidim/core/test/shared_examples/amendable/amendment_promoted_event_examples.rb +3 -3
  154. data/lib/decidim/core/test/shared_examples/comments_examples.rb +26 -0
  155. data/lib/decidim/core/test/shared_examples/has_space_in_mcell_examples.rb +2 -1
  156. data/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb +77 -0
  157. data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +85 -6
  158. data/lib/decidim/core/test/shared_examples/resource_liked_event_examples.rb +3 -3
  159. data/lib/decidim/core/test/shared_examples/versions_controller_examples.rb +23 -1
  160. data/lib/decidim/core/version.rb +1 -1
  161. data/lib/decidim/core.rb +10 -1
  162. data/lib/decidim/has_upload_validations.rb +1 -0
  163. data/lib/decidim/legacy_form_builder.rb +4 -0
  164. data/lib/decidim/map/dynamic_map.rb +3 -1
  165. data/lib/decidim/query_extensions.rb +2 -0
  166. metadata +31 -6
@@ -22,17 +22,7 @@ describe("PasswordToggler", () => {
22
22
  container = document.createElement("div");
23
23
  container.innerHTML = `
24
24
  <div data-controller="password-toggler" class="flex flex-row items-center gap-x-2" data-show-password="Show secret" data-hide-password="Hide Secret" data-hidden-password="Secret is hidden" data-shown-password="Secret is shown">
25
- <div class="input-group__password">
26
- <div class="input-group__password">
27
- <button type="button" aria-controls="token_162" aria-label="Show password">
28
- <svg width="0.75em" height="0.75em" role="img" aria-hidden="true">
29
- <title>eye-line</title>
30
- <use href="/decidim-packs/media/images/remixicon.symbol-e643e553623ffcd49f94.svg#ri-eye-line"></use>
31
- </svg>
32
- </button>
33
- <input type="password" id="token_162" value="AXXXXXXXX" class="w-full" autocomplete="off">
34
- </div>
35
- </div>
25
+ <input type="password" id="token_162" value="AXXXXXXXX" class="w-full" autocomplete="off">
36
26
  <div class="basis-1/4">
37
27
  <button type="button" class="button button__sm button__text-primary" data-clipboard-copy="#token_162">
38
28
  <span>Copy secret</span>
@@ -204,22 +194,23 @@ describe("PasswordToggler", () => {
204
194
  });
205
195
  });
206
196
 
207
- describe("integration with existing markup", () => {
208
- it("works with existing button structure", () => {
209
- const existingButton = passwordElement.querySelector('button[aria-controls="token_162"]');
210
- expect(existingButton).toBeTruthy();
197
+ describe("idempotency", () => {
198
+ it("does not duplicate controls on reconnect", () => {
199
+ controller.disconnect();
200
+ controller.connect();
211
201
 
212
- controller.init();
202
+ const wrappers = passwordElement.querySelectorAll(".input-group__password");
203
+ const buttons = passwordElement.querySelectorAll('button[aria-controls="token_162"]');
213
204
 
214
- // Should work alongside existing button
215
- expect(controller.button).toBeDefined();
216
- expect(controller.input.getAttribute("type")).toBe("password");
205
+ expect(wrappers.length).toBe(1);
206
+ expect(buttons.length).toBe(1);
217
207
  });
208
+ });
218
209
 
210
+ describe("integration with existing markup", () => {
219
211
  it("handles password input with existing value", () => {
220
212
  expect(controller.input.value).toBe("AXXXXXXXX");
221
213
 
222
- controller.init();
223
214
  controller.showPassword();
224
215
 
225
216
  expect(controller.input.getAttribute("type")).toBe("text");
@@ -50,10 +50,7 @@ export const setMinute = (value) => {
50
50
  };
51
51
 
52
52
  export const formatInputDate = (date, formats) => {
53
- const dateList = date.split("-");
54
- const year = dateList[0];
55
- const month = dateList[1];
56
- const day = dateList[2];
53
+ const [year, month, day] = date.split("-");
57
54
 
58
55
  if (formats.order === "m-d-y") {
59
56
  return `${month}${formats.separator}${day}${formats.separator}${year}`;
@@ -63,10 +60,34 @@ export const formatInputDate = (date, formats) => {
63
60
  return `${day}${formats.separator}${month}${formats.separator}${year}`;
64
61
  };
65
62
 
63
+ /**
64
+ * Splits a time string into two elements: hours and minutes. Ensures a returned
65
+ * array of size 2 for any kind of strings or `null` values.
66
+ *
67
+ * Examples:
68
+ * splitTime("") => ["00", "00"]
69
+ * splitTime(null) => ["00", "00"]
70
+ * splitTime("12") => ["12", "00"]
71
+ * splitTime("12:30") => ["12", "30"]
72
+ * splitTime("2:4") => ["02", "04"]
73
+ * splitTime("12:30:45") => ["12", "30"]
74
+ *
75
+ * @param {String} value The original time string, typically hh:ss.
76
+ * @returns {Array} An array with two elements where the first element is the
77
+ * hour part and the second element is the minute part.
78
+ */
79
+ const splitTime = (value) => {
80
+ return [...(value || "").split(":"), "00"].splice(0, 2).map((part) => part.padStart(2, "0"));
81
+ };
82
+
66
83
  export const formatInputTime = (time, format, input) => {
67
- const timeList = time.split(":");
68
- let hour = timeList[0];
69
- const minute = timeList[1];
84
+ if (!time) {
85
+ return "";
86
+ } else if (time.length < 1) {
87
+ return time;
88
+ }
89
+
90
+ let [hour, minute] = splitTime(time);
70
91
 
71
92
  if (format === 12) {
72
93
  if (Number(hour) === 12) {
@@ -80,11 +101,9 @@ export const formatInputTime = (time, format, input) => {
80
101
  } else if (Number(hour) === 0) {
81
102
  hour = "12";
82
103
  }
83
-
84
- return `${hour}:${minute}`;
85
104
  };
86
105
 
87
- return time;
106
+ return `${hour}:${minute}`;
88
107
  };
89
108
 
90
109
  export const changeHourDisplay = (change, hour, format) => {
@@ -199,29 +218,21 @@ export const formatDate = (value, formats) => {
199
218
  };
200
219
 
201
220
  export const formatTime = (value, format, inputname) => {
221
+ let [hour, minute] = splitTime(value);
222
+
202
223
  if (format === 12) {
203
- const splitValue = value.split(":");
204
- let hour = splitValue[0];
205
- const minute = splitValue[1];
206
224
  if (document.getElementById(`period_am_${inputname}`).checked) {
207
- switch (hour) {
208
- case "12":
225
+ if (hour === "12") {
209
226
  hour = "00";
210
-
211
- return `${hour}:${minute}`;
212
- default:
213
- return value;
214
- };
227
+ }
215
228
  } else if (document.getElementById(`period_pm_${inputname}`).checked) {
216
229
  if (Number(hour) > 0 && Number(hour) < 12) {
217
230
  hour = `${Number(hour) + 12}`;
218
231
  };
219
-
220
- return `${hour}:${minute}`
221
232
  };
222
233
  };
223
234
 
224
- return value;
235
+ return `${hour}:${minute}`;
225
236
  };
226
237
 
227
238
  export const updateInputValue = (input, formats, time) => {
@@ -58,7 +58,7 @@ export default function generateDatePicker(input, row, formats) {
58
58
  dateColumn.appendChild(datePickerContainer);
59
59
 
60
60
  let prevDate = null;
61
- let defaultTime = input.getAttribute("default_time") || "00:00"
61
+ let defaultTime = input.dataset.defaultTime || "00:00"
62
62
 
63
63
  const datePickerDisplay = (event) => {
64
64
  if (!dateColumn.contains(event.target)) {
@@ -79,7 +79,7 @@ export default function generateDatePicker(input, row, formats) {
79
79
  if (input.type === "date") {
80
80
  input.value = `${formatDate(date.value, formats)}`;
81
81
  } else if (input.type === "datetime-local") {
82
- input.value = `${formatDate(date.value, formats)}T${formatTime(document.querySelector(`#${input.id}_time`).value, formats.time, input.id) || defaultTime}`;
82
+ input.value = `${formatDate(date.value, formats)}T${formatTime(document.querySelector(`#${input.id}_time`).value || defaultTime, formats.time, input.id)}`;
83
83
  };
84
84
  };
85
85
  });
@@ -95,7 +95,7 @@ export default function generateDatePicker(input, row, formats) {
95
95
  if (input.type === "date") {
96
96
  input.value = `${formatDate(date.value, formats)}`;
97
97
  } else if (input.type === "datetime-local") {
98
- input.value = `${formatDate(date.value, formats)}T${formatTime(document.querySelector(`#${input.id}_time`).value, formats.time, input.id) || defaultTime}`;
98
+ input.value = `${formatDate(date.value, formats)}T${formatTime(document.querySelector(`#${input.id}_time`).value || defaultTime, formats.time, input.id)}`;
99
99
  };
100
100
  };
101
101
  });
@@ -115,7 +115,7 @@ export default function generateDatePicker(input, row, formats) {
115
115
  if (input.type === "date") {
116
116
  input.value = `${pickedDate}`;
117
117
  } else if (input.type === "datetime-local") {
118
- input.value = `${pickedDate}T${formatTime(document.querySelector(`#${input.id}_time`).value, formats.time, input.id) || defaultTime}`;
118
+ input.value = `${pickedDate}T${formatTime(document.querySelector(`#${input.id}_time`).value || defaultTime, formats.time, input.id)}`;
119
119
  };
120
120
  datePickerContainer.style.display = "none";
121
121
  });
@@ -49,34 +49,42 @@ describe("setMinute", () => {
49
49
  });
50
50
 
51
51
  describe("formatInputTime", () => {
52
- const setupDOM = () => {
53
- document.body.innerHTML = `
54
- <input type="radio" id="period_am_exampleInput" name="period">
55
- <input type="radio" id="period_pm_exampleInput" name="period">
56
- `;
57
- };
58
-
59
52
  describe("24-hour format", () => {
53
+ const format = 24;
54
+ const input = { id: "exampleInput" };
55
+
60
56
  it("returns the value in correct format", () => {
61
- setupDOM();
62
- const time = "15:30";
63
- const format = 24;
64
- const input = { id: "exampleInput" };
57
+ const result = formatInputTime("15:30", format, input);
65
58
 
66
- const result = formatInputTime(time, format, input);
59
+ expect(result).toBe("15:30");
60
+ });
61
+
62
+ it("returns the value in correct format if seconds are provided", () => {
63
+ const result = formatInputTime("15:30:04", format, input);
67
64
 
68
65
  expect(result).toBe("15:30");
69
66
  });
67
+
68
+ it("returns an empty string if an empty string is provided", () => {
69
+ const result = formatInputTime("", 24, { id: "exampleInput" });
70
+
71
+ expect(result).toBe("");
72
+ });
70
73
  })
71
74
 
72
75
  describe("12-hour format", () => {
73
- it("returns the value in correct format and checks the PM radio-button", () => {
74
- setupDOM();
75
- const time = "12:45";
76
- const format = 12;
77
- const input = { id: "exampleInput" };
76
+ beforeEach(() => {
77
+ document.body.innerHTML = `
78
+ <input type="radio" id="period_am_exampleInput" name="period">
79
+ <input type="radio" id="period_pm_exampleInput" name="period">
80
+ `;
81
+ });
82
+
83
+ const format = 12;
84
+ const input = { id: "exampleInput" };
78
85
 
79
- const result = formatInputTime(time, format, input);
86
+ it("returns the value in correct format and checks the PM radio-button", () => {
87
+ const result = formatInputTime("12:45", format, input);
80
88
 
81
89
  expect(result).toBe("12:45");
82
90
  expect(document.getElementById("period_pm_exampleInput").checked).toBeTruthy();
@@ -84,17 +92,24 @@ describe("formatInputTime", () => {
84
92
  });
85
93
 
86
94
  it("returns the value in correct format", () => {
87
- setupDOM();
88
- const time = "00:25";
89
- const format = 12;
90
- const input = { id: "exampleInput" };
91
-
92
- const result = formatInputTime(time, format, input);
95
+ const result = formatInputTime("00:25", format, input);
93
96
 
94
97
  expect(result).toBe("12:25");
95
98
  // We are not expecting a radio button to be selected here since in a real environment the selected button is not
96
99
  // handled by this method for values < 12
97
100
  });
101
+
102
+ it("returns the value in correct format if seconds are provided", () => {
103
+ const result = formatInputTime("00:25:04", format, input);
104
+
105
+ expect(result).toBe("12:25");
106
+ });
107
+
108
+ it("returns an empty string if an empty string is provided", () => {
109
+ const result = formatInputTime("", 24, { id: "exampleInput" });
110
+
111
+ expect(result).toBe("");
112
+ });
98
113
  })
99
114
  });
100
115
 
@@ -253,7 +253,8 @@ export default class UploadModal {
253
253
  }
254
254
 
255
255
  setProgressBar(name, value) {
256
- const item = Array.from(this.uploadItems.querySelectorAll("[data-filename]")).find((el) => el.dataset.filename === name);
256
+ const items = Array.from(this.uploadItems.querySelectorAll("[data-filename]"));
257
+ const item = items.filter((el) => el.dataset.filename === name).pop();
257
258
  if (item) {
258
259
  item.querySelector("progress").value = value;
259
260
  }
@@ -29,7 +29,16 @@ export default class MapController {
29
29
  }
30
30
 
31
31
  load() {
32
- this.map = L.map(this.mapId);
32
+ const zoomControl = L.control.zoom({
33
+ zoomInTitle: this.config.zoomInText || "Zoom in",
34
+ zoomOutTitle: this.config.zoomOutText || "Zoom out"
35
+ });
36
+
37
+ this.map = L.map(this.mapId, {
38
+ zoomControl: false
39
+ });
40
+
41
+ this.map.addControl(zoomControl);
33
42
 
34
43
  this.map.scrollWheelZoom.disable();
35
44
 
@@ -5,14 +5,12 @@ const focusableDisableableNodes = ["BUTTON", "INPUT", "TEXTAREA", "SELECT"];
5
5
  export default class FocusGuard {
6
6
  constructor(container) {
7
7
  this.container = container;
8
- this.guardedElement = null;
9
- this.triggerElement = null;
8
+ this.stack = [];
10
9
  }
11
10
 
12
11
  trap(element, trigger) {
13
12
  this.enable();
14
- this.guardedElement = element;
15
- this.triggerElement = trigger;
13
+ this.stack.push({ element, trigger });
16
14
  }
17
15
 
18
16
  enable() {
@@ -45,14 +43,15 @@ export default class FocusGuard {
45
43
  }
46
44
 
47
45
  disable() {
48
- const guards = this.container.querySelectorAll(`:scope > .${focusGuardClass}`);
49
- guards.forEach((guard) => guard.remove());
46
+ const current = this.stack.pop();
50
47
 
51
- this.guardedElement = null;
48
+ if (this.stack.length === 0) {
49
+ const guards = this.container.querySelectorAll(`:scope > .${focusGuardClass}`);
50
+ guards.forEach((guard) => guard.remove());
51
+ }
52
52
 
53
- if (this.triggerElement) {
54
- this.triggerElement.focus();
55
- this.triggerElement = null;
53
+ if (current && current.trigger) {
54
+ current.trigger.focus();
56
55
  }
57
56
  }
58
57
 
@@ -67,12 +66,13 @@ export default class FocusGuard {
67
66
  };
68
67
 
69
68
  handleContainerFocus(guard) {
70
- if (!this.guardedElement) {
69
+ const current = this.stack[this.stack.length - 1];
70
+ if (!current || !current.element) {
71
71
  guard.blur();
72
72
  return;
73
73
  }
74
74
 
75
- const visibleNodes = Array.from(this.guardedElement.querySelectorAll("*")).filter((item) => {
75
+ const visibleNodes = Array.from(current.element.querySelectorAll("*")).filter((item) => {
76
76
  return this.isVisible(item);
77
77
  });
78
78
 
@@ -0,0 +1,136 @@
1
+ /* global jest */
2
+
3
+ import FocusGuard from "src/decidim/refactor/moved/focus_guard"
4
+
5
+ describe("FocusGuard", () => {
6
+ let focusGuard = null;
7
+
8
+ beforeEach(() => {
9
+ document.body.innerHTML = "";
10
+ focusGuard = new FocusGuard(document.body);
11
+ });
12
+
13
+ afterEach(() => {
14
+ document.body.innerHTML = "";
15
+ });
16
+
17
+ describe("single dialog", () => {
18
+ it("creates focus guards on trap", () => {
19
+ const dialog = document.createElement("div");
20
+ const trigger = document.createElement("button");
21
+ document.body.appendChild(dialog);
22
+ document.body.appendChild(trigger);
23
+
24
+ focusGuard.trap(dialog, trigger);
25
+ const guards = document.body.querySelectorAll(".focusguard");
26
+ expect(guards.length).toBe(2);
27
+ });
28
+
29
+ it("removes focus guards and returns focus on disable", () => {
30
+ const dialog = document.createElement("div");
31
+ const trigger = document.createElement("button");
32
+ document.body.appendChild(dialog);
33
+ document.body.appendChild(trigger);
34
+
35
+ focusGuard.trap(dialog, trigger);
36
+ focusGuard.disable();
37
+
38
+ const guards = document.body.querySelectorAll(".focusguard");
39
+ expect(guards.length).toBe(0);
40
+ expect(document.activeElement).toBe(trigger);
41
+ });
42
+ });
43
+
44
+ describe("nested dialogs", () => {
45
+ it("keeps guards when inner dialog is disabled", () => {
46
+ const outerDialog = document.createElement("div");
47
+ const outerTrigger = document.createElement("button");
48
+ const innerDialog = document.createElement("div");
49
+ const innerTrigger = document.createElement("button");
50
+
51
+ document.body.appendChild(outerDialog);
52
+ document.body.appendChild(outerTrigger);
53
+ document.body.appendChild(innerDialog);
54
+ document.body.appendChild(innerTrigger);
55
+
56
+ focusGuard.trap(outerDialog, outerTrigger);
57
+ focusGuard.trap(innerDialog, innerTrigger);
58
+
59
+ expect(document.body.querySelectorAll(".focusguard").length).toBe(2);
60
+
61
+ focusGuard.disable();
62
+
63
+ expect(document.body.querySelectorAll(".focusguard").length).toBe(2);
64
+ expect(document.activeElement).toBe(innerTrigger);
65
+ });
66
+
67
+ it("removes guards when outer dialog is disabled last", () => {
68
+ const outerDialog = document.createElement("div");
69
+ const outerTrigger = document.createElement("button");
70
+ const innerDialog = document.createElement("div");
71
+ const innerTrigger = document.createElement("button");
72
+
73
+ document.body.appendChild(outerDialog);
74
+ document.body.appendChild(outerTrigger);
75
+ document.body.appendChild(innerDialog);
76
+ document.body.appendChild(innerTrigger);
77
+
78
+ focusGuard.trap(outerDialog, outerTrigger);
79
+ focusGuard.trap(innerDialog, innerTrigger);
80
+
81
+ focusGuard.disable();
82
+ focusGuard.disable();
83
+
84
+ expect(document.body.querySelectorAll(".focusguard").length).toBe(0);
85
+ expect(document.activeElement).toBe(outerTrigger);
86
+ });
87
+
88
+ it("cycles focus to the current top dialog when a guard receives focus", () => {
89
+ jest.spyOn(focusGuard, "isVisible").mockReturnValue(true);
90
+
91
+ const outerDialog = document.createElement("div");
92
+ const outerInput = document.createElement("input");
93
+ outerDialog.appendChild(outerInput);
94
+
95
+ const innerDialog = document.createElement("div");
96
+ const innerInput = document.createElement("input");
97
+ innerDialog.appendChild(innerInput);
98
+
99
+ document.body.appendChild(outerDialog);
100
+ document.body.appendChild(innerDialog);
101
+
102
+ focusGuard.trap(outerDialog, null);
103
+ focusGuard.trap(innerDialog, null);
104
+
105
+ const endGuard = document.body.querySelector('.focusguard[data-position="end"]');
106
+ endGuard.focus();
107
+
108
+ expect(document.activeElement).toBe(innerInput);
109
+ });
110
+
111
+ it("cycles focus backward to the current top dialog when the start guard receives focus", () => {
112
+ jest.spyOn(focusGuard, "isVisible").mockReturnValue(true);
113
+
114
+ const outerDialog = document.createElement("div");
115
+ const outerInput = document.createElement("input");
116
+ outerDialog.appendChild(outerInput);
117
+
118
+ const innerDialog = document.createElement("div");
119
+ const innerInput = document.createElement("input");
120
+ const innerButton = document.createElement("button");
121
+ innerDialog.appendChild(innerInput);
122
+ innerDialog.appendChild(innerButton);
123
+
124
+ document.body.appendChild(outerDialog);
125
+ document.body.appendChild(innerDialog);
126
+
127
+ focusGuard.trap(outerDialog, null);
128
+ focusGuard.trap(innerDialog, null);
129
+
130
+ const startGuard = document.body.querySelector('.focusguard[data-position="start"]');
131
+ startGuard.focus();
132
+
133
+ expect(document.activeElement).toBe(innerButton);
134
+ });
135
+ });
136
+ });
@@ -77,7 +77,7 @@ const createUnloadPreventer = () => {
77
77
  ev.preventDefault();
78
78
  ev.stopPropagation();
79
79
 
80
- confirmAction(confirmMessage, link).then((answer) => {
80
+ confirmAction(confirmMessage, link, { iconName: "alert-line" }).then((answer) => {
81
81
  if (!answer) {
82
82
  return;
83
83
  }
@@ -109,7 +109,7 @@ const createUnloadPreventer = () => {
109
109
  ev.stopImmediatePropagation();
110
110
  ev.stopPropagation();
111
111
 
112
- confirmAction(confirmMessage, button).then((answer) => {
112
+ confirmAction(confirmMessage, button, { iconName: "alert-line" }).then((answer) => {
113
113
  if (!answer) {
114
114
  return;
115
115
  }
@@ -55,7 +55,7 @@
55
55
  }
56
56
 
57
57
  &-content {
58
- @apply flex flex-col gap-2.5;
58
+ @apply flex flex-col gap-2.5 min-w-0 break-words;
59
59
  }
60
60
 
61
61
  &-title {
@@ -13,18 +13,12 @@
13
13
  @apply block max-h-40 overflow-hidden relative first:[&_*]:mt-0 before:content-[''] before:absolute before:inset-0 before:h-full before:w-full before:bg-gradient-to-b before:from-transparent before:to-white after:content-[''] after:absolute after:inset-0 after:h-full after:w-full after:bg-gradient-to-b after:from-transparent after:to-white;
14
14
  }
15
15
 
16
- [aria-expanded="false"] > svg:last-of-type,
17
- [aria-expanded="false"] > span:last-of-type,
18
- [aria-expanded="true"] > span:first-of-type,
19
- [aria-expanded="true"] > svg:first-of-type {
20
- @apply hidden;
16
+ [aria-expanded] > svg {
17
+ @apply transition-transform;
21
18
  }
22
19
 
23
- [aria-expanded="true"] > svg:last-of-type,
24
- [aria-expanded="true"] > span:last-of-type,
25
- [aria-expanded="false"] > span:first-of-type,
26
- [aria-expanded="false"] > svg:first-of-type {
27
- @apply block;
20
+ [aria-expanded="true"] > svg {
21
+ @apply rotate-180;
28
22
  }
29
23
  }
30
24
 
@@ -92,14 +92,6 @@
92
92
  [data-dialog-closable] {
93
93
  @apply top-6 right-6;
94
94
  }
95
-
96
- [data-dialog-container] {
97
- @apply inline grid-cols-none;
98
- }
99
-
100
- [data-dialog-title] {
101
- @apply col-span-2 md:col-span-1 md:col-start-2 grid grid-cols-[auto_1fr] items-start md:items-center gap-2 pb-4 text-left text-xl text-gray-2 md:border-b md:border-gray-3;
102
- }
103
95
  }
104
96
 
105
97
  &-results {
@@ -65,17 +65,25 @@
65
65
  &-panel {
66
66
  @apply divide-y divide-gray-3 [&>*]:py-4 [&>p]:text-gray-2;
67
67
 
68
- &__tr {
69
- @apply flex items-center justify-between rounded first-of-type:bg-background-4;
68
+ table {
69
+ @apply w-full border-collapse;
70
70
  }
71
71
 
72
- &__th {
73
- @apply w-1/4 p-2 bg-background-4 text-gray-2 font-bold text-sm;
72
+ thead tr {
73
+ @apply bg-background-4;
74
74
  }
75
75
 
76
- &__td {
76
+ th {
77
+ @apply w-1/4 p-2 text-gray-2 font-bold text-sm text-left;
78
+ }
79
+
80
+ td {
77
81
  @apply w-1/4 p-2 text-gray-2 text-xs;
78
82
  }
83
+
84
+ tbody tr {
85
+ @apply border-b border-gray-3;
86
+ }
79
87
  }
80
88
 
81
89
  &-toggle {
@@ -3,7 +3,7 @@
3
3
  @apply divide-y divide-background [&>*]:py-4 first:[&>*]:pt-0 last:[&>*]:pb-0;
4
4
 
5
5
  > * {
6
- @apply flex items-center justify-between gap-4;
6
+ @apply grid sm:flex items-center justify-between gap-4;
7
7
  }
8
8
  }
9
9
 
@@ -3,12 +3,44 @@ footer {
3
3
  @apply bg-gray-4;
4
4
 
5
5
  &__top {
6
- @apply hidden lg:flex flex-row gap-8 container py-10;
6
+ @apply flex flex-col lg:flex-row gap-0 lg:gap-8 container py-10;
7
7
 
8
8
  .prose a,
9
9
  .prose strong {
10
10
  @apply text-white;
11
11
  }
12
+
13
+ .footer__nav {
14
+ @apply border-b border-white/20 lg:border-0;
15
+
16
+ ul {
17
+ @apply space-y-4 break-inside-avoid mb-4;
18
+
19
+ li a {
20
+ @apply font-semibold underline decoration-1 underline-offset-1;
21
+ }
22
+ }
23
+
24
+ > [data-controls] {
25
+ @apply flex items-center justify-between w-full py-4 lg:pointer-events-none lg:cursor-default text-left text-white font-semibold;
26
+
27
+ svg {
28
+ @apply w-4 h-4 fill-current transition-transform lg:hidden;
29
+ }
30
+
31
+ &[aria-expanded="true"] svg {
32
+ @apply rotate-180;
33
+ }
34
+ }
35
+
36
+ [id*="panel"][aria-hidden="true"] {
37
+ @apply hidden lg:block;
38
+ }
39
+
40
+ [id*="panel"][aria-hidden="false"] {
41
+ @apply block;
42
+ }
43
+ }
12
44
  }
13
45
 
14
46
  &__down {