decidim-core 0.31.3 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/nav_links/show.erb +3 -3
  3. data/app/cells/decidim/participatory_space_private_user/show.erb +6 -6
  4. data/app/cells/decidim/participatory_space_private_user_cell.rb +0 -4
  5. data/app/helpers/decidim/mailer_helper.rb +36 -0
  6. data/app/helpers/decidim/menu_helper.rb +2 -1
  7. data/app/helpers/decidim/newsletters_helper.rb +4 -22
  8. data/app/mailers/decidim/application_mailer.rb +4 -0
  9. data/app/packs/src/decidim/controllers/accordion/accordion.test.js +118 -0
  10. data/app/packs/src/decidim/controllers/accordion/controller.js +24 -0
  11. data/app/packs/src/decidim/controllers/dropdown/controller.js +26 -0
  12. data/app/packs/src/decidim/controllers/dropdown/dropdown.test.js +187 -0
  13. data/app/packs/src/decidim/controllers/form_validator/form_validator.js +3 -2
  14. data/app/packs/src/decidim/controllers/form_validator/form_validator.test.js +5 -0
  15. data/app/packs/src/decidim/editor/extensions/image/index.js +49 -11
  16. data/app/packs/src/decidim/editor/extensions/image/node_view.js +9 -1
  17. data/app/packs/src/decidim/editor/extensions/link/bubble_menu.js +34 -6
  18. data/app/packs/src/decidim/editor/extensions/link/index.js +45 -12
  19. data/app/packs/src/decidim/editor/test/extensions/image_links.test.js +161 -0
  20. data/app/packs/stylesheets/decidim/_rich_text.scss +17 -0
  21. data/app/packs/stylesheets/decidim/editor.scss +10 -0
  22. data/app/presenters/decidim/menu_item_presenter.rb +7 -1
  23. data/app/views/decidim/devise/registrations/new.html.erb +1 -0
  24. data/app/views/decidim/devise/shared/_tos_fields.html.erb +3 -3
  25. data/app/views/decidim/notification_mailer/event_received.html.erb +3 -3
  26. data/app/views/decidim/pages/_tabbed.html.erb +3 -3
  27. data/app/views/decidim/shared/_filters.html.erb +5 -5
  28. data/app/views/decidim/shared/filters/_check_boxes_tree.html.erb +1 -1
  29. data/app/views/decidim/shared/filters/_collection.html.erb +1 -1
  30. data/config/locales/de.yml +27 -0
  31. data/config/locales/eu.yml +3 -0
  32. data/config/locales/fi-plain.yml +5 -0
  33. data/config/locales/fi.yml +5 -0
  34. data/lib/decidim/content_parsers/blob_parser.rb +3 -3
  35. data/lib/decidim/content_renderers/blob_renderer.rb +2 -2
  36. data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +121 -0
  37. data/lib/decidim/core/version.rb +1 -1
  38. data/lib/decidim/participatory_space_user.rb +1 -1
  39. metadata +11 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c44cbb210028e54b7a2bcc3a10fc79e041e355eac55e81b9a4b934f5fb5b42db
4
- data.tar.gz: 0d87937801924b8d92ef31e2b136019090118d89b46ddbe5f2ce4052eef207b0
3
+ metadata.gz: 513dc9560cc17e12439bcef89efbb034edc6300b791810f2037bbb64191805a6
4
+ data.tar.gz: 99d7f076a772765592d34f5164659c5f4d0826edb4d146d5917fff28e02dc922
5
5
  SHA512:
6
- metadata.gz: 508ad856daf2345e66f3c6b0b01c1a0c851d225eca8d0e4e739d79f5584dacb6de8c0155b8a3f28ac80961f6346c31ef3d6fdc14f80299fd80184ea64697b4ef
7
- data.tar.gz: 931b27cfcafe94a6f922d26c2c419dbf89386f64147234d5cf129641b52e2651e877cbfff63ec13f9844c5e6f91c12730df30f0fea2a5dffe2f6c61551bee522
6
+ metadata.gz: d8928571dc994667af951c63981ce2472ce1be5db0c477beb27cadfc2ac43b6cfa87f1a8ab6854483cd00ce46fb1856adacf593ad4e159e7e1a46dc3ae099b87
7
+ data.tar.gz: e03706f2aadaac2165c6a513a5b1284011bda181ab5a29683fe5983e616c880fd9c7a6ff15cbf1619fb6d6f658eb30df40e4d151cb81f12b0bdbd16c7848b224
@@ -1,12 +1,12 @@
1
1
  <div class="participatory-space__nav-container">
2
- <button id="dropdown-trigger-participatory-space" data-controller="dropdown" data-target="dropdown-menu-participatory-space" data-auto-close="true" data-scroll-to-menu="true">
2
+ <button id="dropdown-trigger-participatory-space" data-controller="dropdown" data-target="dropdown-menu-participatory-space" data-auto-close="true" data-scroll-to-menu="true" data-add-aria-roles="false" data-open-md="true">
3
3
  <span><%= t("decidim.searches.filters.jump_to") %></span>
4
4
  <%= icon "arrow-down-s-line" %>
5
5
  <%= icon "arrow-up-s-line" %>
6
6
  </button>
7
- <ul id="dropdown-menu-participatory-space" class="participatory-space__nav" aria-hidden="true">
7
+ <ul id="dropdown-menu-participatory-space" class="participatory-space__nav">
8
8
  <% model.each do |item| %>
9
- <li role="menuitem">
9
+ <li>
10
10
  <%= link_to item[:url], class: "participatory-space__nav-item" do %>
11
11
  <%= decidim_escape_translated(item[:name]) %>
12
12
  <%= icon "arrow-right-line" %>
@@ -1,13 +1,13 @@
1
- <div class="profile__user">
1
+ <%= link_to profile_url, class: "profile__user" do %>
2
2
  <div class="profile__user-avatar-container">
3
- <div class="<%= has_profile? ? "profile__user-avatar" : "profile__user-avatar !border-0" %>">
4
- <%= image_tag(has_profile? ? model.avatar_url(:big) : model.non_user_avatar_path, alt: "member-avatar") %>
3
+ <div class="profile__user-avatar">
4
+ <%= image_tag(model.avatar_url(:big), alt: "member-avatar") %>
5
5
  </div>
6
6
  </div>
7
7
  <div>
8
- <div class="<%= has_profile? ? "profile__user-name" : "profile__user-name !no-underline" %>">
8
+ <span class="profile__user-name">
9
9
  <%= name %>
10
- </div>
10
+ </span>
11
11
  <% if nickname.present? %>
12
12
  <span class="profile__user-nick block">
13
13
  <%= nickname %>
@@ -20,4 +20,4 @@
20
20
  </span>
21
21
  </div>
22
22
  </div>
23
- </div>
23
+ <% end %>
@@ -10,10 +10,6 @@ module Decidim
10
10
 
11
11
  private
12
12
 
13
- def has_profile?
14
- model.profile_url.present?
15
- end
16
-
17
13
  def role_translated
18
14
  decidim_html_escape(decidim_sanitize(translated_attribute(role)))
19
15
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ # Helper that provides methods to render order selector and links
5
+ module MailerHelper
6
+ # Transforms relative image URLs in HTML content to absolute URLs using the provided host.
7
+ # This is used in emails (newsletters and notifications) to ensure images display correctly
8
+ # in email clients.
9
+ #
10
+ # @param content [String] - HTML content with img tags
11
+ # @param host [String] - the Decidim::Organization host to use for the root URL
12
+ #
13
+ # @return [String] - the content with transformed image URLs
14
+ def decidim_transform_image_urls(content, host)
15
+ return content if host.blank? || content.blank?
16
+
17
+ root_url = if Decidim.storage_cdn_host.present?
18
+ Decidim.storage_cdn_host.chomp("/")
19
+ else
20
+ Decidim::EngineRouter.new("decidim", {}).root_url(host:).chomp("/")
21
+ end
22
+
23
+ content.gsub(/src\s*=\s*(['"])([^'"]*)\1/) do
24
+ quote = Regexp.last_match(1)
25
+ src_value = Regexp.last_match(2)
26
+
27
+ if src_value.blank? || src_value.start_with?("http://", "https://", "data:", "//", "cid:")
28
+ %(src=#{quote}#{src_value}#{quote})
29
+ else
30
+ normalized_src = src_value.start_with?("/") ? src_value : "/#{src_value}"
31
+ %(src=#{quote}#{root_url}#{normalized_src}#{quote})
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -57,7 +57,8 @@ module Decidim
57
57
  self,
58
58
  element_class: "font-semibold underline",
59
59
  active_class: "is-active",
60
- container_options: { class: "space-y-4 break-inside-avoid", role: :menu },
60
+ role: false,
61
+ container_options: { class: "space-y-4 break-inside-avoid" },
61
62
  label: t("layouts.decidim.footer.decidim_title")
62
63
  )
63
64
  end
@@ -3,6 +3,9 @@
3
3
  module Decidim
4
4
  # Helper that provides methods to render links with utm codes, and replaced name
5
5
  module NewslettersHelper
6
+ include Decidim::SanitizeHelper
7
+ include Decidim::MailerHelper
8
+
6
9
  # If the newsletter body there are some links and the Decidim.track_newsletter_links = true
7
10
  # it will be replaced with the utm_codes method described below.
8
11
  # for example transform "https://es.lipsum.com/" to "https://es.lipsum.com/?utm_source=localhost&utm_campaign=newsletter_11"
@@ -19,7 +22,7 @@ module Decidim
19
22
 
20
23
  content = interpret_name(content, user)
21
24
  content = track_newsletter_links(content, id, host)
22
- transform_image_urls(content, host)
25
+ decidim_transform_image_urls(content, host)
23
26
  end
24
27
 
25
28
  # this method is used to generate the root link on mail with the utm_codes
@@ -67,27 +70,6 @@ module Decidim
67
70
  content.gsub("%{name}", user.name)
68
71
  end
69
72
 
70
- # Find each img HTML tag with relative path in src attribute
71
- # For each URL, prepends the decidim.root_url
72
- # If host is not defined it returns full content
73
- #
74
- # @param content [String] - the string to convert
75
- # @param host [String] - the Decidim::Organization host to replace
76
- #
77
- # @return [String] - the content converted
78
- #
79
- def transform_image_urls(content, host)
80
- return content if host.blank?
81
-
82
- content.scan(/src\s*=\s*"([^"]*)"/).each do |src|
83
- root_url = decidim.root_url(host:)[0..-2]
84
- src_replaced = "#{root_url}#{src.first}"
85
- content = content.gsub(/src\s*=\s*"([^"]*#{src.first})"/, %(src="#{src_replaced}"))
86
- end
87
-
88
- content
89
- end
90
-
91
73
  # Add tracking query params to each links
92
74
  #
93
75
  # @param content [String] - the string to convert
@@ -7,9 +7,13 @@ module Decidim
7
7
  include LocalisedMailer
8
8
  include MultitenantAssetHost
9
9
  include Decidim::SanitizeHelper
10
+ include Decidim::MailerHelper
10
11
  include Decidim::OrganizationHelper
11
12
  helper_method :organization_name, :decidim_escape_translated, :decidim_sanitize_translated, :translated_attribute, :decidim_sanitize, :decidim_sanitize_newsletter
12
13
 
14
+ helper Decidim::SanitizeHelper
15
+ helper Decidim::MailerHelper
16
+
13
17
  after_action :set_smtp
14
18
  after_action :set_from
15
19
 
@@ -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