decidim-core 0.32.0 → 0.32.2

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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/comments_button_cell.rb +9 -1
  3. data/app/cells/decidim/content_blocks/participatory_space_main_data/title.erb +1 -14
  4. data/app/cells/decidim/data_consent/category.erb +20 -33
  5. data/app/cells/decidim/following_cell.rb +1 -1
  6. data/app/cells/decidim/footer_topics/show.erb +5 -8
  7. data/app/cells/decidim/member/show.erb +1 -1
  8. data/app/cells/decidim/nav_links/show.erb +3 -1
  9. data/app/cells/decidim/share_text_widget/modal.erb +1 -0
  10. data/app/cells/decidim/share_widget/modal.erb +1 -0
  11. data/app/cells/decidim/share_widget/qr_code_modal.erb +2 -4
  12. data/app/commands/decidim/create_omniauth_registration.rb +17 -2
  13. data/app/controllers/concerns/decidim/devise_authentication_methods.rb +4 -0
  14. data/app/controllers/concerns/decidim/devise_controllers.rb +2 -0
  15. data/app/controllers/concerns/decidim/disable_redirection_to_external_host.rb +2 -0
  16. data/app/controllers/concerns/decidim/filter_resource.rb +2 -0
  17. data/app/controllers/concerns/decidim/impersonate_users.rb +2 -2
  18. data/app/controllers/concerns/decidim/locale_switcher.rb +12 -10
  19. data/app/controllers/concerns/decidim/needs_permission.rb +22 -10
  20. data/app/controllers/concerns/decidim/onboarding_action_methods.rb +2 -0
  21. data/app/controllers/concerns/decidim/paginable.rb +7 -3
  22. data/app/controllers/concerns/decidim/payload_info.rb +2 -0
  23. data/app/controllers/concerns/decidim/resource_versions_concern.rb +14 -0
  24. data/app/controllers/decidim/application_controller.rb +0 -6
  25. data/app/controllers/decidim/authorization_modals_controller.rb +2 -0
  26. data/app/controllers/decidim/components/base_controller.rb +2 -0
  27. data/app/controllers/decidim/devise/passwords_controller.rb +8 -0
  28. data/app/controllers/decidim/favicon_controller.rb +9 -0
  29. data/app/controllers/decidim/free_resource_authorization_modals_controller.rb +2 -0
  30. data/app/controllers/decidim/gamification/badges_controller.rb +2 -0
  31. data/app/controllers/decidim/homepage_controller.rb +4 -1
  32. data/app/controllers/decidim/likes_controller.rb +1 -1
  33. data/app/controllers/decidim/links_controller.rb +2 -0
  34. data/app/controllers/decidim/manifests_controller.rb +2 -0
  35. data/app/controllers/decidim/messaging/conversations_controller.rb +10 -4
  36. data/app/controllers/decidim/newsletters_controller.rb +4 -0
  37. data/app/controllers/decidim/notifications_subscriptions_controller.rb +4 -0
  38. data/app/controllers/decidim/offline_controller.rb +5 -1
  39. data/app/controllers/decidim/open_data_controller.rb +5 -1
  40. data/app/controllers/decidim/pages_controller.rb +31 -3
  41. data/app/controllers/decidim/private_downloads_controller.rb +2 -0
  42. data/app/controllers/decidim/profiles_controller.rb +19 -1
  43. data/app/controllers/decidim/qr_controller.rb +3 -1
  44. data/app/controllers/decidim/resource_autocomplete_controller.rb +2 -0
  45. data/app/controllers/decidim/searches_controller.rb +2 -0
  46. data/app/controllers/decidim/short_links_controller.rb +4 -0
  47. data/app/controllers/decidim/static_map_controller.rb +2 -0
  48. data/app/controllers/decidim/timeouts_controller.rb +4 -0
  49. data/app/controllers/decidim/tos_controller.rb +2 -0
  50. data/app/controllers/decidim/upload_validations_controller.rb +4 -2
  51. data/app/controllers/decidim/user_activities_controller.rb +3 -0
  52. data/app/forms/decidim/upload_validation_form.rb +16 -10
  53. data/app/helpers/concerns/decidim/user_role_checker.rb +35 -10
  54. data/app/helpers/decidim/decidim_form_helper.rb +1 -1
  55. data/app/helpers/decidim/menu_helper.rb +0 -2
  56. data/app/helpers/decidim/sanitize_helper.rb +11 -1
  57. data/app/jobs/decidim/application_job.rb +4 -0
  58. data/app/models/decidim/authorization.rb +24 -0
  59. data/app/models/decidim/oauth_application.rb +1 -0
  60. data/app/models/decidim/user.rb +3 -2
  61. data/app/models/decidim/user_base_entity.rb +1 -1
  62. data/app/packs/src/decidim/callout.js +10 -0
  63. data/app/packs/src/decidim/callout.test.js +32 -0
  64. data/app/packs/src/decidim/controllers/accordion/accordion.test.js +260 -42
  65. data/app/packs/src/decidim/controllers/accordion/controller.js +136 -17
  66. data/app/packs/src/decidim/controllers/autofocus/controller.js +30 -0
  67. data/app/packs/src/decidim/controllers/dropdown/controller.js +6 -0
  68. data/app/packs/src/decidim/controllers/dropdown/dropdown.test.js +28 -0
  69. data/app/packs/src/decidim/controllers/language_change/controller.js +21 -8
  70. data/app/packs/src/decidim/controllers/language_change/language_change.test.js +0 -1
  71. data/app/packs/src/decidim/controllers/main_menu/controller.js +4 -0
  72. data/app/packs/src/decidim/controllers/main_menu/main_menu.test.js +23 -0
  73. data/app/packs/src/decidim/controllers/mention/controller.js +32 -3
  74. data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +8 -5
  75. data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +1 -1
  76. data/app/packs/src/decidim/controllers/password_toggler/controller.js +22 -1
  77. data/app/packs/src/decidim/controllers/password_toggler/password_toggler.test.js +11 -20
  78. data/app/packs/src/decidim/datepicker/datepicker_functions.js +41 -25
  79. data/app/packs/src/decidim/datepicker/generate_datepicker.js +4 -4
  80. data/app/packs/src/decidim/datepicker/test/datepicker_functions_adjust_picker_position.test.js +64 -0
  81. data/app/packs/src/decidim/datepicker/test/time.test.js +39 -24
  82. data/app/packs/src/decidim/direct_uploads/upload_modal.js +2 -1
  83. data/app/packs/src/decidim/editor/common/suggestion.js +32 -22
  84. data/app/packs/src/decidim/editor/extensions/mention/index.js +14 -3
  85. data/app/packs/src/decidim/editor/index.js +4 -0
  86. data/app/packs/src/decidim/editor/test/extensions/mention.test.js +32 -9
  87. data/app/packs/src/decidim/map/controller.js +10 -1
  88. data/app/packs/src/decidim/refactor/implementation/autofocus.js +22 -0
  89. data/app/packs/src/decidim/refactor/implementation/autofocus.test.js +171 -0
  90. data/app/packs/src/decidim/refactor/moved/focus_guard.js +12 -12
  91. data/app/packs/src/decidim/refactor/moved/focus_guard.test.js +136 -0
  92. data/app/packs/src/decidim/utilities/dom.js +2 -2
  93. data/app/packs/stylesheets/decidim/_cards.scss +1 -1
  94. data/app/packs/stylesheets/decidim/_content_blocks.scss +4 -10
  95. data/app/packs/stylesheets/decidim/_conversations.scss +0 -8
  96. data/app/packs/stylesheets/decidim/_cookies.scss +13 -5
  97. data/app/packs/stylesheets/decidim/_documents.scss +1 -1
  98. data/app/packs/stylesheets/decidim/_footer.scss +33 -1
  99. data/app/packs/stylesheets/decidim/_modal.scss +16 -8
  100. data/app/packs/stylesheets/decidim/_modal_share.scss +0 -22
  101. data/app/packs/stylesheets/decidim/_participatory_spaces.scss +12 -2
  102. data/app/packs/stylesheets/decidim/_rich_text.scss +12 -9
  103. data/app/packs/stylesheets/decidim/_typography.scss +5 -2
  104. data/app/packs/stylesheets/decidim/application.scss +1 -0
  105. data/app/packs/stylesheets/decidim/legacy/email.scss +0 -5
  106. data/app/permissions/decidim/permissions.rb +20 -0
  107. data/app/presenters/decidim/admin_log/component_presenter.rb +1 -1
  108. data/app/presenters/decidim/footer_menu_presenter.rb +2 -4
  109. data/app/presenters/decidim/log/base_presenter.rb +10 -1
  110. data/app/presenters/decidim/log/user_presenter.rb +2 -2
  111. data/app/presenters/decidim/menu_item_presenter.rb +1 -5
  112. data/app/presenters/decidim/resource_presenter.rb +11 -0
  113. data/app/resolvers/decidim/meta_image_url_resolver.rb +14 -1
  114. data/app/serializers/decidim/exporters/open_data_blocked_user_serializer.rb +11 -2
  115. data/app/serializers/decidim/exporters/open_data_moderation_serializer.rb +25 -1
  116. data/app/services/decidim/svg_content_validator.rb +554 -0
  117. data/app/uploaders/decidim/application_uploader.rb +2 -2
  118. data/app/validators/uploader_image_content_validator.rb +276 -0
  119. data/app/views/decidim/application/_radio_accordion.html.erb +1 -5
  120. data/app/views/decidim/messaging/conversations/_add_conversation_users.html.erb +1 -1
  121. data/app/views/decidim/messaging/conversations/create.js.erb +1 -3
  122. data/app/views/decidim/searches/_filters.html.erb +1 -1
  123. data/app/views/decidim/shared/_login_modal.html.erb +12 -14
  124. data/app/views/layouts/decidim/_mailer_logo.html.erb +1 -1
  125. data/app/views/layouts/decidim/footer/_main.html.erb +1 -1
  126. data/app/views/layouts/decidim/footer/_main_links.html.erb +56 -28
  127. data/app/views/layouts/decidim/header/_menu_breadcrumb_desktop.html.erb +3 -2
  128. data/config/initializers/rack_attack.rb +10 -0
  129. data/config/locales/bg.yml +0 -1
  130. data/config/locales/ca-IT.yml +9 -1
  131. data/config/locales/ca.yml +16 -6
  132. data/config/locales/cs.yml +31 -1
  133. data/config/locales/de.yml +99 -3
  134. data/config/locales/en.yml +11 -1
  135. data/config/locales/es-MX.yml +11 -1
  136. data/config/locales/es-PY.yml +11 -1
  137. data/config/locales/es.yml +15 -5
  138. data/config/locales/eu.yml +166 -161
  139. data/config/locales/fi-plain.yml +9 -1
  140. data/config/locales/fi.yml +9 -1
  141. data/config/locales/fr-CA.yml +12 -1
  142. data/config/locales/fr.yml +12 -1
  143. data/config/locales/gl.yml +1485 -33
  144. data/config/locales/hu.yml +0 -1
  145. data/config/locales/ja.yml +28 -1
  146. data/config/locales/pl.yml +2 -1
  147. data/config/locales/pt-BR.yml +0 -1
  148. data/config/locales/ro-RO.yml +0 -1
  149. data/config/locales/sk.yml +2 -1
  150. data/config/locales/sv.yml +68 -12
  151. data/config/locales/tr-TR.yml +0 -2
  152. data/config/routes.rb +11 -2
  153. data/db/migrate/20250217192438_convert_user_groups_into_users.rb +3 -2
  154. data/db/migrate/20260909000000_add_failed_attempts_and_locked_at_to_decidim_authorizations.rb +8 -0
  155. data/decidim-core.gemspec +4 -1
  156. data/lib/decidim/api/functions/user_entity_list.rb +1 -0
  157. data/lib/decidim/api/types/user_moderation_type.rb +2 -2
  158. data/lib/decidim/content_parsers/user_parser.rb +1 -1
  159. data/lib/decidim/content_parsers.rb +0 -1
  160. data/lib/decidim/content_renderers/mention_resource_renderer.rb +18 -4
  161. data/lib/decidim/content_renderers/user_renderer.rb +15 -4
  162. data/lib/decidim/core/test/shared_examples/access_mode_restricted_participatory_spaces.rb +8 -5
  163. data/lib/decidim/core/test/shared_examples/access_mode_transparent_participatory_spaces.rb +14 -11
  164. data/lib/decidim/core/test/shared_examples/comments_examples.rb +26 -0
  165. data/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb +77 -0
  166. data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +58 -0
  167. data/lib/decidim/core/test/shared_examples/versions_controller_examples.rb +23 -1
  168. data/lib/decidim/core/version.rb +1 -1
  169. data/lib/decidim/core.rb +10 -1
  170. data/lib/decidim/form_builder.rb +9 -1
  171. data/lib/decidim/has_upload_validations.rb +1 -0
  172. data/lib/decidim/legacy_form_builder.rb +4 -0
  173. data/lib/decidim/map/dynamic_map.rb +3 -1
  174. metadata +33 -7
  175. data/lib/decidim/content_parsers/inline_images_parser.rb +0 -68
  176. data/lib/tasks/upgrade/decidim_active_storage_migration_tasks.rake +0 -79
@@ -1,36 +1,45 @@
1
+ /* eslint max-lines: ["error", 350] */
1
2
  /* global jest */
2
3
 
3
4
  import AccordionController from "src/decidim/controllers/accordion/controller";
5
+ import { screens } from "tailwindcss/defaultTheme"
4
6
 
5
7
  jest.mock("a11y-accordion-component", () => ({
6
8
  render: jest.fn(),
7
9
  destroy: jest.fn()
8
10
  }));
9
11
 
12
+ const createController = (controllerElement) => {
13
+ const instance = Object.create(AccordionController.prototype);
14
+ Reflect.defineProperty(instance, "element", {
15
+ get: () => controllerElement,
16
+ configurable: true
17
+ });
18
+ return instance;
19
+ };
20
+
10
21
  describe("AccordionController", () => {
11
22
  let controller = null;
12
23
  let accordionElement = null;
13
24
  let panel1 = null;
14
25
  let panel2 = null;
15
26
 
16
- const createController = (controllerElement) => {
17
- const ControllerClass = AccordionController;
18
- const instance = Object.create(ControllerClass.prototype);
19
- Reflect.defineProperty(instance, "element", {
20
- get: () => controllerElement,
21
- configurable: true
27
+ const mockMatchMedia = (matchesOrPredicate = false) => {
28
+ window.matchMedia = jest.fn().mockImplementation((query) => {
29
+ const matches = typeof matchesOrPredicate === "function"
30
+ ? matchesOrPredicate(query)
31
+ : matchesOrPredicate;
32
+ return {
33
+ matches,
34
+ media: query,
35
+ addEventListener: jest.fn(),
36
+ removeEventListener: jest.fn()
37
+ };
22
38
  });
23
- return instance;
24
39
  };
25
40
 
26
41
  beforeEach(() => {
27
- window.matchMedia = jest.fn().mockImplementation((query) => ({
28
- matches: false,
29
- media: query,
30
- addListener: jest.fn(),
31
- removeListener: jest.fn()
32
- }));
33
-
42
+ mockMatchMedia(false);
34
43
  document.body.innerHTML = `
35
44
  <div id="test-accordion" data-controller="accordion">
36
45
  <button id="trigger-1" data-controls="panel-1">Trigger 1</button>
@@ -39,11 +48,9 @@ describe("AccordionController", () => {
39
48
  <div id="panel-2">Panel 2 Content</div>
40
49
  </div>
41
50
  `;
42
-
43
51
  accordionElement = document.getElementById("test-accordion");
44
52
  panel1 = document.getElementById("panel-1");
45
53
  panel2 = document.getElementById("panel-2");
46
-
47
54
  controller = createController(accordionElement);
48
55
  });
49
56
 
@@ -56,10 +63,8 @@ describe("AccordionController", () => {
56
63
  it("changes role from region to group when data-panel-role is group", () => {
57
64
  panel1.setAttribute("role", "region");
58
65
  panel2.setAttribute("role", "region");
59
-
60
66
  accordionElement.dataset.panelRole = "group";
61
67
  controller.fixPanelRole();
62
-
63
68
  expect(panel1.getAttribute("role")).toBe("group");
64
69
  expect(panel2.getAttribute("role")).toBe("group");
65
70
  });
@@ -67,52 +72,265 @@ describe("AccordionController", () => {
67
72
  it("removes role attribute when data-panel-role is none", () => {
68
73
  panel1.setAttribute("role", "region");
69
74
  panel2.setAttribute("role", "region");
70
-
71
75
  accordionElement.dataset.panelRole = "none";
72
76
  controller.fixPanelRole();
73
-
74
77
  expect(panel1.getAttribute("role")).toBeNull();
75
78
  expect(panel2.getAttribute("role")).toBeNull();
76
79
  });
77
80
 
78
- it("does nothing when data-panel-role is not set", () => {
79
- panel1.setAttribute("role", "region");
80
- panel2.setAttribute("role", "region");
81
-
82
- Reflect.deleteProperty(accordionElement.dataset, "panelRole");
83
- controller.fixPanelRole();
84
-
85
- expect(panel1.getAttribute("role")).toBe("region");
86
- expect(panel2.getAttribute("role")).toBe("region");
87
- });
88
-
89
- it("does nothing when data-panel-role is empty", () => {
81
+ it("does nothing when data-panel-role is not set or empty", () => {
90
82
  panel1.setAttribute("role", "region");
91
-
92
- accordionElement.dataset.panelRole = "";
93
83
  controller.fixPanelRole();
94
-
95
84
  expect(panel1.getAttribute("role")).toBe("region");
96
85
  });
97
86
 
98
87
  it("sets custom role value when data-panel-role is set", () => {
99
88
  panel1.setAttribute("role", "region");
100
-
101
89
  accordionElement.dataset.panelRole = "navigation";
102
90
  controller.fixPanelRole();
103
-
104
91
  expect(panel1.getAttribute("role")).toBe("navigation");
105
92
  });
106
93
 
107
94
  it("handles nonexistent panels gracefully", () => {
108
95
  accordionElement.dataset.panelRole = "group";
96
+ const trigger = document.createElement("button");
97
+ trigger.dataset.controls = "nonexistent-panel";
98
+ accordionElement.appendChild(trigger);
99
+ expect(() => controller.fixPanelRole()).not.toThrow();
100
+ });
101
+ });
109
102
 
110
- const nonExistentTrigger = document.createElement("button");
111
- nonExistentTrigger.dataset.controls = "nonexistent-panel";
112
- accordionElement.appendChild(nonExistentTrigger);
103
+ describe("_setupInertPanels", () => {
104
+ const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0));
113
105
 
114
- expect(() => controller.fixPanelRole()).not.toThrow();
106
+ beforeEach(() => {
107
+ panel1.setAttribute("inert", "");
108
+ panel1.setAttribute("aria-hidden", "true");
109
+ panel1.setAttribute("tabindex", "-1");
110
+ panel2.setAttribute("aria-hidden", "true");
111
+ });
112
+
113
+ it("only manages panels with the inert attribute", async () => {
114
+ controller._setupInertPanels();
115
+ expect(controller._panelObservers.length).toBe(1);
116
+
117
+ panel2.setAttribute("aria-hidden", "false");
118
+ await nextTick();
119
+ expect(panel2.hasAttribute("inert")).toBe(false);
120
+ expect("inertManaged" in panel2.dataset).toBe(false);
121
+ });
122
+
123
+ it("removes inert and moves focus to the panel when it gets expanded", async () => {
124
+ controller._setupInertPanels();
125
+
126
+ panel1.setAttribute("aria-hidden", "false");
127
+ await nextTick();
128
+ expect(panel1.hasAttribute("inert")).toBe(false);
129
+ expect(document.activeElement).toBe(panel1);
130
+ });
131
+
132
+ it("restores inert when the panel gets collapsed again", async () => {
133
+ controller._setupInertPanels();
134
+
135
+ panel1.setAttribute("aria-hidden", "false");
136
+ await nextTick();
137
+ panel1.setAttribute("aria-hidden", "true");
138
+ await nextTick();
139
+ expect(panel1.hasAttribute("inert")).toBe(true);
140
+ });
141
+
142
+ it("keeps managing an expanded panel across teardown and setup", async () => {
143
+ controller._setupInertPanels();
144
+
145
+ panel1.setAttribute("aria-hidden", "false");
146
+ await nextTick();
147
+ controller._teardownInertPanels();
148
+
149
+ panel1.setAttribute("aria-hidden", "true");
150
+ controller._setupInertPanels();
151
+ expect(controller._panelObservers.length).toBe(1);
152
+ expect(panel1.hasAttribute("inert")).toBe(true);
153
+ });
154
+
155
+ it("stops syncing after teardown", async () => {
156
+ controller._setupInertPanels();
157
+ controller._teardownInertPanels();
158
+ expect(controller._panelObservers.length).toBe(0);
159
+
160
+ panel1.setAttribute("aria-hidden", "false");
161
+ await nextTick();
162
+ expect(panel1.hasAttribute("inert")).toBe(true);
115
163
  });
116
164
  });
117
- });
118
165
 
166
+ describe("_applyBreakpointOpenAttributes", () => {
167
+ beforeEach(() => {
168
+ controller.initialize();
169
+ });
170
+
171
+ it("removes data-open when no breakpoint matches and no original exists", () => {
172
+ accordionElement.innerHTML = `
173
+ <button data-controls="panel-1" data-open-lg="true">Trigger</button>
174
+ <div id="panel-1">Panel 1</div>
175
+ `;
176
+ controller._applyBreakpointOpenAttributes();
177
+ expect(accordionElement.querySelector("[data-controls]").hasAttribute("data-open")).toBe(false);
178
+ });
179
+
180
+ it("sets data-open from breakpoint attribute when viewport matches", () => {
181
+ mockMatchMedia((query) => query === `(min-width: ${screens.lg})`);
182
+ accordionElement.innerHTML = `
183
+ <button data-controls="panel-1" data-open-lg="true">Trigger</button>
184
+ <div id="panel-1">Panel 1</div>
185
+ `;
186
+ controller.initialize();
187
+ controller._applyBreakpointOpenAttributes();
188
+ expect(accordionElement.querySelector("[data-controls]").getAttribute("data-open")).toBe("true");
189
+ });
190
+
191
+ it("restores original data-open when breakpoint no longer matches", () => {
192
+ const lgQuery = `(min-width: ${screens.lg})`;
193
+ accordionElement.innerHTML = `
194
+ <button data-controls="panel-1" data-open="false" data-open-lg="true">Trigger</button>
195
+ <div id="panel-1">Panel 1</div>
196
+ `;
197
+ const trigger = accordionElement.querySelector("[data-controls]");
198
+ controller.initialize();
199
+
200
+ mockMatchMedia((query) => query === lgQuery);
201
+ controller._applyBreakpointOpenAttributes();
202
+ expect(trigger.getAttribute("data-open")).toBe("true");
203
+
204
+ mockMatchMedia(() => false);
205
+ controller._applyBreakpointOpenAttributes();
206
+ expect(trigger.getAttribute("data-open")).toBe("false");
207
+ });
208
+
209
+ it("removes data-open when breakpoint stops matching and no original existed", () => {
210
+ const lgQuery = `(min-width: ${screens.lg})`;
211
+ accordionElement.innerHTML = `
212
+ <button data-controls="panel-1" data-open-lg="true">Trigger</button>
213
+ <div id="panel-1">Panel 1</div>
214
+ `;
215
+ const trigger = accordionElement.querySelector("[data-controls]");
216
+ controller.initialize();
217
+
218
+ mockMatchMedia((query) => query === lgQuery);
219
+ controller._applyBreakpointOpenAttributes();
220
+ expect(trigger.getAttribute("data-open")).toBe("true");
221
+
222
+ mockMatchMedia(() => false);
223
+ controller._applyBreakpointOpenAttributes();
224
+ expect(trigger.hasAttribute("data-open")).toBe(false);
225
+ });
226
+
227
+ it("applies the last matching breakpoint when multiple match", () => {
228
+ mockMatchMedia((query) =>
229
+ query === `(min-width: ${screens.sm})` || query === `(min-width: ${screens.lg})`
230
+ );
231
+ accordionElement.innerHTML = `
232
+ <button data-controls="panel-1" data-open-sm="false" data-open-lg="true">Trigger</button>
233
+ <div id="panel-1">Panel 1</div>
234
+ `;
235
+ controller.initialize();
236
+ controller._applyBreakpointOpenAttributes();
237
+ expect(accordionElement.querySelector("[data-controls]").getAttribute("data-open")).toBe("true");
238
+ });
239
+
240
+ it("leaves triggers without breakpoint attributes unchanged", () => {
241
+ controller.initialize();
242
+ controller._applyBreakpointOpenAttributes();
243
+ expect(accordionElement.querySelector("#trigger-1").hasAttribute("data-open")).toBe(false);
244
+ expect(accordionElement.querySelector("#trigger-2").hasAttribute("data-open")).toBe(false);
245
+ });
246
+ });
247
+
248
+ describe("_listenForBreakpointChanges", () => {
249
+ beforeEach(() => {
250
+ controller.initialize();
251
+ });
252
+
253
+ it("registers change listeners for breakpoints with matching triggers", () => {
254
+ accordionElement.innerHTML = `
255
+ <button data-controls="panel-1" data-open-lg="true">Trigger</button>
256
+ <div id="panel-1">Panel 1</div>
257
+ `;
258
+ controller._listenForBreakpointChanges();
259
+ expect(window.matchMedia).toHaveBeenCalledWith(`(min-width: ${screens.lg})`);
260
+ expect(window.matchMedia.mock.results[0].value.addEventListener).toHaveBeenCalledWith("change", expect.any(Function));
261
+ });
262
+
263
+ it("skips breakpoints without matching triggers", () => {
264
+ accordionElement.innerHTML = `
265
+ <button data-controls="panel-1" data-open-lg="true">Trigger</button>
266
+ <div id="panel-1">Panel 1</div>
267
+ `;
268
+ controller._listenForBreakpointChanges();
269
+ expect(window.matchMedia).toHaveBeenCalledTimes(1);
270
+ });
271
+
272
+ it("registers nothing when there are no breakpoint triggers", () => {
273
+ controller._listenForBreakpointChanges();
274
+ expect(controller._mediaQueries.length).toBe(0);
275
+ });
276
+
277
+ it("registers listeners for multiple breakpoints", () => {
278
+ accordionElement.innerHTML = `
279
+ <button data-controls="panel-1" data-open-sm="true" data-open-lg="false">Trigger</button>
280
+ <div id="panel-1">Panel 1</div>
281
+ `;
282
+ controller._listenForBreakpointChanges();
283
+ expect(window.matchMedia).toHaveBeenCalledWith(`(min-width: ${screens.sm})`);
284
+ expect(window.matchMedia).toHaveBeenCalledWith(`(min-width: ${screens.lg})`);
285
+ expect(controller._mediaQueries.length).toBe(2);
286
+ });
287
+ });
288
+
289
+ describe("_stopListeningForBreakpointChanges", () => {
290
+ beforeEach(() => {
291
+ controller.initialize();
292
+ });
293
+
294
+ it("removes all registered change listeners", () => {
295
+ accordionElement.innerHTML = `
296
+ <button data-controls="panel-1" data-open-lg="true">Trigger</button>
297
+ <div id="panel-1">Panel 1</div>
298
+ `;
299
+ controller._listenForBreakpointChanges();
300
+ const mql = window.matchMedia.mock.results[0].value;
301
+ controller._stopListeningForBreakpointChanges();
302
+ expect(mql.removeEventListener).toHaveBeenCalledWith("change", expect.any(Function));
303
+ expect(controller._mediaQueries.length).toBe(0);
304
+ });
305
+
306
+ it("does nothing when no listeners exist", () => {
307
+ expect(() => controller._stopListeningForBreakpointChanges()).not.toThrow();
308
+ });
309
+ });
310
+
311
+ describe("connect/disconnect/reconnect with breakpoints", () => {
312
+ beforeEach(() => {
313
+ controller.initialize();
314
+ });
315
+
316
+ it("connect applies breakpoint attributes and sets up listeners", () => {
317
+ const applySpy = jest.spyOn(controller, "_applyBreakpointOpenAttributes");
318
+ const listenSpy = jest.spyOn(controller, "_listenForBreakpointChanges");
319
+ controller.connect();
320
+ expect(applySpy).toHaveBeenCalled();
321
+ expect(listenSpy).toHaveBeenCalled();
322
+ });
323
+
324
+ it("disconnect tears down breakpoint listeners", () => {
325
+ const spy = jest.spyOn(controller, "_stopListeningForBreakpointChanges");
326
+ controller.disconnect();
327
+ expect(spy).toHaveBeenCalled();
328
+ });
329
+
330
+ it("reconnect re-evaluates breakpoint attributes", () => {
331
+ const spy = jest.spyOn(controller, "_applyBreakpointOpenAttributes");
332
+ controller.reconnect({});
333
+ expect(spy).toHaveBeenCalled();
334
+ });
335
+ });
336
+ });
@@ -2,6 +2,9 @@ import { Controller } from "@hotwired/stimulus"
2
2
  import Accordions from "a11y-accordion-component";
3
3
  import { screens } from "tailwindcss/defaultTheme"
4
4
  export default class extends Controller {
5
+ initialize() {
6
+ this._originalOpenValues = new WeakMap();
7
+ }
5
8
 
6
9
  /**
7
10
  * Create accordion from a component
@@ -16,21 +19,8 @@ export default class extends Controller {
16
19
  accordionOptions.isMultiSelectable = this.element.dataset.multiselectable !== "false";
17
20
  accordionOptions.isCollapsible = this.element.dataset.collapsible !== "false";
18
21
 
19
- // This snippet allows to change the OPEN data-attribute based on the current viewport
20
- // Just include the breakpoint where the different value will be applied from.
21
- // Ex:
22
- // data-open="false" data-open-md="true"
23
- Object.keys(screens).forEach((key) => {
24
- if (!this.isScreenSize(key)) {
25
- return;
26
- }
27
-
28
- const elementsToOpen = this.element.querySelectorAll(`[data-controls][data-open-${key}]`);
29
-
30
- elementsToOpen.forEach((elem) => {
31
- (elem.dataset.open = elem.dataset[`open-${key}`.replace(/-([a-z])/g, (str) => str[1].toUpperCase())])
32
- })
33
- })
22
+ this._applyBreakpointOpenAttributes();
23
+ this._listenForBreakpointChanges();
34
24
 
35
25
  if (!this.element.id) {
36
26
  // when component has no id, we enforce to have it one
@@ -41,6 +31,8 @@ export default class extends Controller {
41
31
 
42
32
  this.fixPanelRole();
43
33
 
34
+ this._setupInertPanels();
35
+
44
36
  this.expandIfNeeded();
45
37
 
46
38
  this.boundReconnect = this.reconnect.bind(this);
@@ -59,20 +51,108 @@ export default class extends Controller {
59
51
  }
60
52
 
61
53
  if (this.boundExpand) {
62
- this.toggleButton.addEventListener("click", this.boundExpand);
54
+ this.toggleButton.removeEventListener("click", this.boundExpand);
63
55
  }
56
+
57
+ this._teardownInertPanels();
58
+
59
+ this._stopListeningForBreakpointChanges();
64
60
  }
65
61
 
66
62
  reconnect(event) {
67
63
  this.disconnect();
68
64
 
69
- if (event.detail && event.detail.collapse) {
65
+ if (event && event.detail && event.detail.collapse) {
70
66
  this.previouslyExpanded = false;
71
67
  }
72
68
 
73
69
  this.connect();
74
70
  }
75
71
 
72
+ /**
73
+ * Applies viewport-conditional data-open attributes and resets
74
+ * previously applied values when the viewport no longer matches.
75
+ *
76
+ * @returns {void}
77
+ */
78
+ _applyBreakpointOpenAttributes() {
79
+ const controlledTriggers = new Set();
80
+
81
+ Object.keys(screens).forEach((key) => {
82
+ this.element.querySelectorAll(`[data-controls][data-open-${key}]`).forEach((elem) => {
83
+ controlledTriggers.add(elem);
84
+
85
+ if (!this._originalOpenValues.has(elem)) {
86
+ this._originalOpenValues.set(elem, elem.getAttribute("data-open"));
87
+ }
88
+ });
89
+ });
90
+
91
+ // Reset all controlled triggers to their original data-open state
92
+ controlledTriggers.forEach((elem) => {
93
+ const original = this._originalOpenValues.get(elem);
94
+ if (original === null) {
95
+ elem.removeAttribute("data-open");
96
+ } else {
97
+ elem.setAttribute("data-open", original);
98
+ }
99
+ });
100
+
101
+ // Apply breakpoint-specific values for the current viewport
102
+ Object.keys(screens).forEach((key) => {
103
+ if (!this.isScreenSize(key)) {
104
+ return;
105
+ }
106
+
107
+ this.element.querySelectorAll(`[data-controls][data-open-${key}]`).forEach((elem) => {
108
+ elem.dataset.open = elem.getAttribute(`data-open-${key}`);
109
+ });
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Listens for viewport changes that cross breakpoints relevant
115
+ * to this accordion and reconnects to keep state in sync.
116
+ *
117
+ * @returns {void}
118
+ */
119
+ _listenForBreakpointChanges() {
120
+ this._mediaQueries = [];
121
+
122
+ Object.keys(screens).forEach((key) => {
123
+ const hasRelevantTriggers = this.element.querySelector(`[data-controls][data-open-${key}]`);
124
+ if (!hasRelevantTriggers) {
125
+ return;
126
+ }
127
+
128
+ const mql = window.matchMedia(`(min-width: ${screens[key]})`);
129
+ const handler = () => {
130
+ this.reconnect({ detail: { collapse: true } });
131
+ };
132
+
133
+ if (mql.addEventListener) {
134
+ mql.addEventListener("change", handler);
135
+ } else if (mql.addListener) {
136
+ mql.addListener(handler);
137
+ }
138
+
139
+ this._mediaQueries.push({ mql, handler });
140
+ });
141
+ }
142
+
143
+ _stopListeningForBreakpointChanges() {
144
+ if (this._mediaQueries) {
145
+ this._mediaQueries.forEach(({ mql, handler }) => {
146
+ if (mql.removeEventListener) {
147
+ mql.removeEventListener("change", handler);
148
+ } else if (mql.removeListener) {
149
+ mql.removeListener(handler);
150
+ }
151
+ });
152
+ this._mediaQueries = [];
153
+ }
154
+ }
155
+
76
156
  expandIfNeeded()
77
157
  {
78
158
  if (!this.toggleButton) {
@@ -90,6 +170,45 @@ export default class extends Controller {
90
170
  this.previouslyExpanded = this.toggleButton.getAttribute("aria-expanded");
91
171
  }
92
172
 
173
+ /**
174
+ * Syncs the `inert` attribute of partially visible panels (those rendered
175
+ * inert) with their `aria-hidden` state, moving focus to the panel on expand.
176
+ *
177
+ * @returns {void}
178
+ */
179
+ _setupInertPanels() {
180
+ this._panelObservers = [];
181
+
182
+ this.element.querySelectorAll("[data-controls]").forEach((trigger) => {
183
+ const panel = document.getElementById(trigger.dataset.controls);
184
+ if (!panel || (!panel.hasAttribute("inert") && !("inertManaged" in panel.dataset))) {
185
+ return;
186
+ }
187
+
188
+ panel.dataset.inertManaged = "";
189
+ panel.toggleAttribute("inert", panel.getAttribute("aria-hidden") === "true");
190
+
191
+ const observer = new MutationObserver(() => {
192
+ if (panel.getAttribute("aria-hidden") === "true") {
193
+ panel.setAttribute("inert", "");
194
+ } else {
195
+ panel.removeAttribute("inert");
196
+ panel.focus();
197
+ }
198
+ });
199
+ observer.observe(panel, { attributes: true, attributeFilter: ["aria-hidden"] });
200
+
201
+ this._panelObservers.push(observer);
202
+ });
203
+ }
204
+
205
+ _teardownInertPanels() {
206
+ if (this._panelObservers) {
207
+ this._panelObservers.forEach((observer) => observer.disconnect());
208
+ this._panelObservers = [];
209
+ }
210
+ }
211
+
93
212
  fixPanelRole() {
94
213
  const panelRole = this.element.dataset.panelRole;
95
214
  if (!panelRole) {
@@ -0,0 +1,30 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import autofocus from "src/decidim/refactor/implementation/autofocus"
3
+
4
+ export default class extends Controller {
5
+ connect() {
6
+ this.handleTabsChange = this.handleTabsChange.bind(this)
7
+
8
+ // Foundation tabs (used for translated fields when locales <= 4)
9
+ this.tabs = Array.from(this.element.querySelectorAll("ul[data-tabs]"))
10
+ if (typeof $ === "function" && this.tabs.length > 0) {
11
+ this.tabs.forEach((tabs) => $(tabs).on("change.zf.tabs", this.handleTabsChange))
12
+ }
13
+
14
+ // Language selector dropdown (used for translated fields when locales > 4)
15
+ if (this.element.querySelector("select.language-change")) {
16
+ autofocus(this.element)
17
+ }
18
+ }
19
+
20
+ disconnect() {
21
+ if (typeof $ === "function" && this.tabs && this.tabs.length > 0) {
22
+ this.tabs.forEach((tabs) => $(tabs).off("change.zf.tabs", this.handleTabsChange))
23
+ }
24
+ }
25
+
26
+ handleTabsChange(event) {
27
+ const activePane = $(event.target).parent().next(".tabs-content").find(".tabs-panel.is-active")[0]
28
+ autofocus(activePane)
29
+ }
30
+ }
@@ -47,6 +47,12 @@ export default class extends Controller {
47
47
  this.element.id = `dropdown-${Math.random().toString(36).substring(7)}`
48
48
  }
49
49
 
50
+ if (!this.element.hasAttribute("aria-expanded")) {
51
+ this.element.setAttribute("aria-expanded", dropdownOptions.isOpen
52
+ ? "true"
53
+ : "false");
54
+ }
55
+
50
56
  const autofocus = this.element.dataset.autofocus;
51
57
  if (autofocus) {
52
58
  // set the focus to some inner element, use setTimeout hack due to waiting for element to display
@@ -142,6 +142,34 @@ describe("DropdownController", () => {
142
142
  });
143
143
  });
144
144
 
145
+ describe("aria-expanded initialization", () => {
146
+ it("sets aria-expanded to false when missing and isOpen is false", () => {
147
+ const testController = createController(element);
148
+
149
+ testController.connect();
150
+
151
+ expect(element.getAttribute("aria-expanded")).toBe("false");
152
+ });
153
+
154
+ it("sets aria-expanded to true when missing and data-open is true", () => {
155
+ element.setAttribute("data-open", "true");
156
+ const testController = createController(element);
157
+
158
+ testController.connect();
159
+
160
+ expect(element.getAttribute("aria-expanded")).toBe("true");
161
+ });
162
+
163
+ it("does not override existing aria-expanded attribute", () => {
164
+ element.setAttribute("aria-expanded", "true");
165
+ const testController = createController(element);
166
+
167
+ testController.connect();
168
+
169
+ expect(element.getAttribute("aria-expanded")).toBe("true");
170
+ });
171
+ });
172
+
145
173
  describe("data-add-aria-roles option", () => {
146
174
  it("keeps role menu when data-add-aria-roles is true", () => {
147
175
  element.setAttribute("data-add-aria-roles", "true");