decidim-core 0.31.2 → 0.31.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/content_blocks/highlighted_elements_with_cell_for_list_cell.rb +5 -1
  3. data/app/cells/decidim/nav_links/show.erb +3 -3
  4. data/app/cells/decidim/participatory_space_private_user/show.erb +6 -6
  5. data/app/cells/decidim/participatory_space_private_user_cell.rb +0 -4
  6. data/app/cells/decidim/report_button/already_reported_modal.erb +1 -1
  7. data/app/cells/decidim/report_button/flag_modal.erb +1 -1
  8. data/app/cells/decidim/report_user_button/already_reported_modal.erb +1 -1
  9. data/app/cells/decidim/report_user_button/flag_modal.erb +1 -1
  10. data/app/cells/decidim/share_text_widget/modal.erb +1 -1
  11. data/app/cells/decidim/upload_modal/files.erb +5 -1
  12. data/app/cells/decidim/upload_modal_cell.rb +10 -1
  13. data/app/commands/decidim/multiple_attachments_methods.rb +20 -3
  14. data/app/helpers/decidim/mailer_helper.rb +36 -0
  15. data/app/helpers/decidim/menu_helper.rb +2 -1
  16. data/app/helpers/decidim/newsletters_helper.rb +4 -22
  17. data/app/jobs/decidim/find_and_update_descendants_job.rb +8 -2
  18. data/app/jobs/decidim/update_search_indexes_job.rb +2 -2
  19. data/app/mailers/decidim/application_mailer.rb +4 -0
  20. data/app/packs/src/decidim/a11y.js +29 -0
  21. data/app/packs/src/decidim/a11y.test.js +81 -0
  22. data/app/packs/src/decidim/confirm.js +8 -1
  23. data/app/packs/src/decidim/confirm.test.js +225 -0
  24. data/app/packs/src/decidim/controllers/accordion/accordion.test.js +118 -0
  25. data/app/packs/src/decidim/controllers/accordion/controller.js +24 -0
  26. data/app/packs/src/decidim/controllers/dropdown/controller.js +26 -0
  27. data/app/packs/src/decidim/controllers/dropdown/dropdown.test.js +187 -0
  28. data/app/packs/src/decidim/controllers/form_validator/form_validator.js +3 -2
  29. data/app/packs/src/decidim/controllers/form_validator/form_validator.test.js +5 -0
  30. data/app/packs/src/decidim/controllers/language_change/controller.js +1 -0
  31. data/app/packs/src/decidim/controllers/language_change/language_change.test.js +13 -0
  32. data/app/packs/src/decidim/datepicker/datepicker_functions.js +26 -0
  33. data/app/packs/src/decidim/datepicker/generate_datepicker.js +2 -1
  34. data/app/packs/src/decidim/datepicker/generate_timepicker.js +3 -2
  35. data/app/packs/src/decidim/datepicker/test/datepicker_functions_adjust_picker_position.test.js +234 -0
  36. data/app/packs/src/decidim/editor/extensions/image/index.js +49 -11
  37. data/app/packs/src/decidim/editor/extensions/image/node_view.js +9 -1
  38. data/app/packs/src/decidim/editor/extensions/link/bubble_menu.js +34 -6
  39. data/app/packs/src/decidim/editor/extensions/link/index.js +45 -12
  40. data/app/packs/src/decidim/editor/test/extensions/image_links.test.js +161 -0
  41. data/app/packs/src/decidim/refactor/moved/focus_guard.js +4 -4
  42. data/app/packs/stylesheets/decidim/_cards.scss +12 -4
  43. data/app/packs/stylesheets/decidim/_flash.scss +1 -1
  44. data/app/packs/stylesheets/decidim/_rich_text.scss +17 -0
  45. data/app/packs/stylesheets/decidim/editor.scss +10 -0
  46. data/app/presenters/decidim/menu_item_presenter.rb +7 -1
  47. data/app/views/decidim/devise/invitations/edit.html.erb +3 -3
  48. data/app/views/decidim/devise/registrations/new.html.erb +1 -0
  49. data/app/views/decidim/devise/shared/_tos_fields.html.erb +3 -3
  50. data/app/views/decidim/notification_mailer/event_received.html.erb +3 -3
  51. data/app/views/decidim/pages/_tabbed.html.erb +3 -3
  52. data/app/views/decidim/shared/_filters.html.erb +5 -5
  53. data/app/views/decidim/shared/filters/_check_boxes_tree.html.erb +1 -1
  54. data/app/views/decidim/shared/filters/_collection.html.erb +1 -1
  55. data/config/initializers/devise.rb +6 -0
  56. data/config/locales/ar.yml +3 -3
  57. data/config/locales/bg.yml +0 -4
  58. data/config/locales/ca-IT.yml +7 -6
  59. data/config/locales/ca.yml +7 -6
  60. data/config/locales/cs.yml +5 -8
  61. data/config/locales/de.yml +31 -8
  62. data/config/locales/el.yml +0 -2
  63. data/config/locales/en.yml +5 -4
  64. data/config/locales/es-MX.yml +10 -9
  65. data/config/locales/es-PY.yml +10 -9
  66. data/config/locales/es.yml +12 -11
  67. data/config/locales/eu.yml +7 -5
  68. data/config/locales/fi-plain.yml +10 -4
  69. data/config/locales/fi.yml +11 -5
  70. data/config/locales/fr-CA.yml +7 -5
  71. data/config/locales/fr.yml +8 -7
  72. data/config/locales/gl.yml +0 -2
  73. data/config/locales/hu.yml +4 -8
  74. data/config/locales/id-ID.yml +0 -2
  75. data/config/locales/it.yml +1 -3
  76. data/config/locales/ja.yml +7 -8
  77. data/config/locales/lb.yml +0 -2
  78. data/config/locales/lt.yml +1 -3
  79. data/config/locales/lv.yml +0 -2
  80. data/config/locales/nl.yml +0 -2
  81. data/config/locales/no.yml +0 -2
  82. data/config/locales/pl.yml +0 -4
  83. data/config/locales/pt-BR.yml +4 -5
  84. data/config/locales/pt.yml +0 -2
  85. data/config/locales/ro-RO.yml +1 -5
  86. data/config/locales/ru.yml +0 -2
  87. data/config/locales/sk.yml +0 -4
  88. data/config/locales/sv.yml +8 -7
  89. data/config/locales/tr-TR.yml +17 -5
  90. data/config/locales/zh-CN.yml +0 -2
  91. data/config/locales/zh-TW.yml +1 -3
  92. data/lib/decidim/assets/tailwind/tailwind.config.js.erb +1 -1
  93. data/lib/decidim/content_parsers/blob_parser.rb +3 -3
  94. data/lib/decidim/content_renderers/blob_renderer.rb +2 -2
  95. data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +121 -0
  96. data/lib/decidim/core/version.rb +1 -1
  97. data/lib/decidim/form_builder.rb +58 -36
  98. data/lib/decidim/maintenance/taxonomy_importer.rb +1 -1
  99. data/lib/decidim/participatory_space_user.rb +1 -1
  100. data/lib/decidim/searchable.rb +4 -4
  101. metadata +14 -6
@@ -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 */
@@ -0,0 +1,118 @@
1
+ /* global jest */
2
+
3
+ import AccordionController from "src/decidim/controllers/accordion/controller";
4
+
5
+ jest.mock("a11y-accordion-component", () => ({
6
+ render: jest.fn(),
7
+ destroy: jest.fn()
8
+ }));
9
+
10
+ describe("AccordionController", () => {
11
+ let controller = null;
12
+ let accordionElement = null;
13
+ let panel1 = null;
14
+ let panel2 = null;
15
+
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
22
+ });
23
+ return instance;
24
+ };
25
+
26
+ beforeEach(() => {
27
+ window.matchMedia = jest.fn().mockImplementation((query) => ({
28
+ matches: false,
29
+ media: query,
30
+ addListener: jest.fn(),
31
+ removeListener: jest.fn()
32
+ }));
33
+
34
+ document.body.innerHTML = `
35
+ <div id="test-accordion" data-controller="accordion">
36
+ <button id="trigger-1" data-controls="panel-1">Trigger 1</button>
37
+ <div id="panel-1">Panel 1 Content</div>
38
+ <button id="trigger-2" data-controls="panel-2">Trigger 2</button>
39
+ <div id="panel-2">Panel 2 Content</div>
40
+ </div>
41
+ `;
42
+
43
+ accordionElement = document.getElementById("test-accordion");
44
+ panel1 = document.getElementById("panel-1");
45
+ panel2 = document.getElementById("panel-2");
46
+
47
+ controller = createController(accordionElement);
48
+ });
49
+
50
+ afterEach(() => {
51
+ document.body.innerHTML = "";
52
+ Reflect.deleteProperty(window, "matchMedia");
53
+ });
54
+
55
+ describe("fixPanelRole", () => {
56
+ it("changes role from region to group when data-panel-role is group", () => {
57
+ panel1.setAttribute("role", "region");
58
+ panel2.setAttribute("role", "region");
59
+
60
+ accordionElement.dataset.panelRole = "group";
61
+ controller.fixPanelRole();
62
+
63
+ expect(panel1.getAttribute("role")).toBe("group");
64
+ expect(panel2.getAttribute("role")).toBe("group");
65
+ });
66
+
67
+ it("removes role attribute when data-panel-role is none", () => {
68
+ panel1.setAttribute("role", "region");
69
+ panel2.setAttribute("role", "region");
70
+
71
+ accordionElement.dataset.panelRole = "none";
72
+ controller.fixPanelRole();
73
+
74
+ expect(panel1.getAttribute("role")).toBeNull();
75
+ expect(panel2.getAttribute("role")).toBeNull();
76
+ });
77
+
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", () => {
90
+ panel1.setAttribute("role", "region");
91
+
92
+ accordionElement.dataset.panelRole = "";
93
+ controller.fixPanelRole();
94
+
95
+ expect(panel1.getAttribute("role")).toBe("region");
96
+ });
97
+
98
+ it("sets custom role value when data-panel-role is set", () => {
99
+ panel1.setAttribute("role", "region");
100
+
101
+ accordionElement.dataset.panelRole = "navigation";
102
+ controller.fixPanelRole();
103
+
104
+ expect(panel1.getAttribute("role")).toBe("navigation");
105
+ });
106
+
107
+ it("handles nonexistent panels gracefully", () => {
108
+ accordionElement.dataset.panelRole = "group";
109
+
110
+ const nonExistentTrigger = document.createElement("button");
111
+ nonExistentTrigger.dataset.controls = "nonexistent-panel";
112
+ accordionElement.appendChild(nonExistentTrigger);
113
+
114
+ expect(() => controller.fixPanelRole()).not.toThrow();
115
+ });
116
+ });
117
+ });
118
+
@@ -39,6 +39,8 @@ export default class extends Controller {
39
39
 
40
40
  Accordions.render(this.element.id, accordionOptions);
41
41
 
42
+ this.fixPanelRole();
43
+
42
44
  this.expandIfNeeded();
43
45
 
44
46
  this.boundReconnect = this.reconnect.bind(this);
@@ -88,6 +90,28 @@ export default class extends Controller {
88
90
  this.previouslyExpanded = this.toggleButton.getAttribute("aria-expanded");
89
91
  }
90
92
 
93
+ fixPanelRole() {
94
+ const panelRole = this.element.dataset.panelRole;
95
+ if (!panelRole) {
96
+ return;
97
+ }
98
+
99
+ const panels = this.element.querySelectorAll("[data-controls]");
100
+ panels.forEach((trigger) => {
101
+ const panelId = trigger.dataset.controls;
102
+ const panel = document.getElementById(panelId);
103
+ if (!panel) {
104
+ return;
105
+ }
106
+
107
+ if (panelRole === "none") {
108
+ panel.removeAttribute("role");
109
+ } else {
110
+ panel.setAttribute("role", panelRole);
111
+ }
112
+ });
113
+ }
114
+
91
115
  /**
92
116
  * Checks if a key is in the current viewport
93
117
  *
@@ -76,6 +76,32 @@ export default class extends Controller {
76
76
  }
77
77
 
78
78
  Dropdowns.render(this.element.id, dropdownOptions);
79
+
80
+ const addAriaRoles = this.element.dataset.addAriaRoles !== "false";
81
+ if (!addAriaRoles) {
82
+ this.removeAriaRoles();
83
+ }
84
+ }
85
+
86
+ removeAriaRoles() {
87
+ const target = this.element.dataset.target;
88
+ const dropdownMenu = document.getElementById(target);
89
+ if (!dropdownMenu) {
90
+ return;
91
+ }
92
+
93
+ dropdownMenu.removeAttribute("role");
94
+ dropdownMenu.removeAttribute("aria-labelledby");
95
+ dropdownMenu.removeAttribute("tabindex");
96
+
97
+ dropdownMenu.querySelectorAll("li").forEach((li) => {
98
+ li.removeAttribute("role");
99
+ });
100
+
101
+ dropdownMenu.querySelectorAll("a").forEach((anchor) => {
102
+ anchor.removeAttribute("role");
103
+ anchor.removeAttribute("tabindex");
104
+ });
79
105
  }
80
106
 
81
107
  /**
@@ -0,0 +1,187 @@
1
+ /* global jest */
2
+ /* eslint max-lines: ["error", 400] */
3
+
4
+ import DropdownController from "src/decidim/controllers/dropdown/controller";
5
+
6
+ jest.mock("a11y-dropdown-component", () => ({
7
+ render: jest.fn(),
8
+ destroy: jest.fn()
9
+ }));
10
+
11
+ describe("DropdownController", () => {
12
+ let element = null;
13
+ let dropdownMenuEl = null;
14
+ let controller = null;
15
+
16
+ const createController = (controllerElement) => {
17
+ const ControllerClass = DropdownController;
18
+ const instance = Object.create(ControllerClass.prototype);
19
+ Reflect.defineProperty(instance, "element", {
20
+ get: () => controllerElement,
21
+ configurable: true
22
+ });
23
+ return instance;
24
+ };
25
+
26
+ beforeEach(() => {
27
+ window.matchMedia = jest.fn().mockImplementation((query) => ({
28
+ matches: false,
29
+ media: query,
30
+ addListener: jest.fn(),
31
+ removeListener: jest.fn()
32
+ }));
33
+
34
+ document.body.innerHTML = `
35
+ <button
36
+ id="dropdown-trigger"
37
+ data-controller="dropdown"
38
+ data-target="dropdown-menu"
39
+ data-open-md="true"
40
+ data-auto-close="true"
41
+ >
42
+ Dropdown Trigger
43
+ </button>
44
+ <ul id="dropdown-menu" class="dropdown-menu">
45
+ <li><a href="/link1">Link 1</a></li>
46
+ <li><a href="/link2">Link 2</a></li>
47
+ <li><a href="/link3">Link 3</a></li>
48
+ </ul>
49
+ `;
50
+
51
+ element = document.getElementById("dropdown-trigger");
52
+ dropdownMenuEl = document.getElementById("dropdown-menu");
53
+ controller = createController(element);
54
+ });
55
+
56
+ afterEach(() => {
57
+ document.body.innerHTML = "";
58
+ Reflect.deleteProperty(window, "matchMedia");
59
+ });
60
+
61
+ describe("removeAriaRoles", () => {
62
+ it("removes role attribute from dropdown menu", () => {
63
+ dropdownMenuEl.setAttribute("role", "menu");
64
+
65
+ controller.removeAriaRoles();
66
+
67
+ expect(dropdownMenuEl.getAttribute("role")).toBeNull();
68
+ });
69
+
70
+ it("removes aria-labelledby attribute from dropdown menu", () => {
71
+ dropdownMenuEl.setAttribute("aria-labelledby", "trigger");
72
+
73
+ controller.removeAriaRoles();
74
+
75
+ expect(dropdownMenuEl.getAttribute("aria-labelledby")).toBeNull();
76
+ });
77
+
78
+ it("removes tabindex attribute from dropdown menu", () => {
79
+ dropdownMenuEl.setAttribute("tabindex", "-1");
80
+
81
+ controller.removeAriaRoles();
82
+
83
+ expect(dropdownMenuEl.getAttribute("tabindex")).toBeNull();
84
+ });
85
+
86
+ it("removes role from li elements", () => {
87
+ const li = dropdownMenuEl.querySelector("li");
88
+ li.setAttribute("role", "none");
89
+
90
+ controller.removeAriaRoles();
91
+
92
+ expect(li.getAttribute("role")).toBeNull();
93
+ });
94
+
95
+ it("removes role from all li elements", () => {
96
+ const listItems = dropdownMenuEl.querySelectorAll("li");
97
+ listItems.forEach((li) => {
98
+ li.setAttribute("role", "none");
99
+ });
100
+
101
+ controller.removeAriaRoles();
102
+
103
+ listItems.forEach((li) => {
104
+ expect(li.getAttribute("role")).toBeNull();
105
+ });
106
+ });
107
+
108
+ it("removes role from anchor elements", () => {
109
+ const anchor = dropdownMenuEl.querySelector("a");
110
+ anchor.setAttribute("role", "menuitem");
111
+
112
+ controller.removeAriaRoles();
113
+
114
+ expect(anchor.getAttribute("role")).toBeNull();
115
+ });
116
+
117
+ it("removes tabindex from anchor elements", () => {
118
+ const anchor = dropdownMenuEl.querySelector("a");
119
+ anchor.setAttribute("tabindex", "-1");
120
+
121
+ controller.removeAriaRoles();
122
+
123
+ expect(anchor.getAttribute("tabindex")).toBeNull();
124
+ });
125
+
126
+ it("handles missing dropdown menu gracefully", () => {
127
+ const mockElement = document.createElement("button");
128
+ mockElement.dataset.target = "nonexistent-menu";
129
+ const mockController = createController(mockElement);
130
+
131
+ expect(() => {
132
+ mockController.removeAriaRoles();
133
+ }).not.toThrow();
134
+ });
135
+
136
+ it("handles elements without the attributes gracefully", () => {
137
+ expect(() => {
138
+ controller.removeAriaRoles();
139
+ }).not.toThrow();
140
+
141
+ expect(dropdownMenuEl.getAttribute("role")).toBeNull();
142
+ });
143
+ });
144
+
145
+ describe("data-add-aria-roles option", () => {
146
+ it("keeps role menu when data-add-aria-roles is true", () => {
147
+ element.setAttribute("data-add-aria-roles", "true");
148
+ dropdownMenuEl.setAttribute("role", "menu");
149
+ dropdownMenuEl.querySelector("li").setAttribute("role", "none");
150
+ dropdownMenuEl.querySelector("a").setAttribute("role", "menuitem");
151
+ const testController = createController(element);
152
+
153
+ testController.connect();
154
+
155
+ expect(dropdownMenuEl.getAttribute("role")).toBe("menu");
156
+ expect(dropdownMenuEl.querySelector("li").getAttribute("role")).toBe("none");
157
+ expect(dropdownMenuEl.querySelector("a").getAttribute("role")).toBe("menuitem");
158
+ });
159
+
160
+ it("keeps role menu when data-add-aria-roles is not set (default)", () => {
161
+ dropdownMenuEl.setAttribute("role", "menu");
162
+ dropdownMenuEl.querySelector("li").setAttribute("role", "none");
163
+ dropdownMenuEl.querySelector("a").setAttribute("role", "menuitem");
164
+ const testController = createController(element);
165
+
166
+ testController.connect();
167
+
168
+ expect(dropdownMenuEl.getAttribute("role")).toBe("menu");
169
+ expect(dropdownMenuEl.querySelector("li").getAttribute("role")).toBe("none");
170
+ expect(dropdownMenuEl.querySelector("a").getAttribute("role")).toBe("menuitem");
171
+ });
172
+
173
+ it("removes role menu when data-add-aria-roles is false", () => {
174
+ element.setAttribute("data-add-aria-roles", "false");
175
+ dropdownMenuEl.setAttribute("role", "menu");
176
+ dropdownMenuEl.querySelector("li").setAttribute("role", "none");
177
+ dropdownMenuEl.querySelector("a").setAttribute("role", "menuitem");
178
+ const testController = createController(element);
179
+
180
+ testController.connect();
181
+
182
+ expect(dropdownMenuEl.getAttribute("role")).toBeNull();
183
+ expect(dropdownMenuEl.querySelector("li").getAttribute("role")).toBeNull();
184
+ expect(dropdownMenuEl.querySelector("a").getAttribute("role")).toBeNull();
185
+ });
186
+ });
187
+ });
@@ -104,7 +104,7 @@ class FormValidator {
104
104
  let announceElement = this.element.querySelector(".sr-announce");
105
105
 
106
106
  if (announceElement) {
107
- announceElement.remove();
107
+ return;
108
108
  }
109
109
 
110
110
  announceElement = document.createElement("div");
@@ -113,7 +113,7 @@ class FormValidator {
113
113
  this.element.prepend(announceElement);
114
114
 
115
115
  setTimeout(() => {
116
- announceElement.textContent = getDictionary("forms.correct_errors");
116
+ announceElement.textContent = getDictionary("forms").correct_errors;
117
117
  }, 100);
118
118
  }
119
119
 
@@ -259,6 +259,7 @@ class FormValidator {
259
259
  this.removeInputErrorClasses(inputElement);
260
260
  } else {
261
261
  this.addInputErrorClasses(inputElement, failedValidatorNames);
262
+ this.announceFormErrorForScreenReader();
262
263
  }
263
264
  }
264
265
 
@@ -522,6 +522,11 @@ describe("FormValidator", () => {
522
522
  validatorInstance.validateSingleInput(textInput);
523
523
  expect(textInput.classList.contains("is-invalid-input")).toBe(true);
524
524
 
525
+ const announceElement = formElement.querySelector(".sr-announce");
526
+ expect(announceElement).toBeTruthy();
527
+ expect(announceElement.getAttribute("aria-live")).toBe("assertive");
528
+ expect(announceElement.classList.contains("sr-only")).toBe(true);
529
+
525
530
  // Reset validation
526
531
  validatorInstance.resetFormValidation();
527
532
 
@@ -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", () => {