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,5 +1,3 @@
1
- /* global jest */
2
-
3
1
  export const createSuggestionRenderer = (node, { itemConverter } = {}) => () => {
4
2
  let suggestion = null;
5
3
  let suggestionItems = null;
@@ -55,25 +53,25 @@ export const createSuggestionRenderer = (node, { itemConverter } = {}) => () =>
55
53
  return;
56
54
  }
57
55
 
58
- const command = selectCommand;
59
- if (currentRange && !window.isTestEnvironment && typeof jest === "undefined") {
60
- // Fixes an issue that after selecting the item, the written text will be
61
- // placed after the newly added suggestion.
62
- //
63
- // NOTE: With JSDom/Jest this does not work even if we add a delay after
64
- // changing the text in the selection. This is because the range remains
65
- // the same for the `command` below which is why the underlying code is
66
- // trying to do an insertion at a position that is out of range after we
67
- // have already deleted the content.
68
- currentEditor.chain().focus().setTextSelection(currentRange).command(({ tr, dispatch }) => {
69
- if (dispatch) {
70
- tr.replaceSelectionWith(currentEditor.schema.text(" "));
71
- }
72
-
73
- return true;
74
- }).setTextSelection({ from: currentRange.from, to: currentRange.from }).run();
56
+ const item = convertItem(items[idx]);
57
+ if (currentRange) {
58
+ const docSize = currentEditor.state.doc.content.size;
59
+ const isValidRange = currentRange.from >= 0 && currentRange.to <= docSize && currentRange.from <= currentRange.to;
60
+
61
+ if (isValidRange) {
62
+ const nodeType = currentEditor.schema.nodes[items[idx].__typename === "User"
63
+ ? "mention"
64
+ : "mentionResource"];
65
+ currentEditor.chain().focus().setTextSelection(currentRange).insertContent({
66
+ type: nodeType.name,
67
+ attrs: item
68
+ }).insertContent(" ").run();
69
+ return;
70
+ }
75
71
  }
76
- command(convertItem(items[idx]));
72
+
73
+ const command = selectCommand;
74
+ command(item);
77
75
  };
78
76
 
79
77
  const showSuggestions = ({ items, clientRect }) => {
@@ -87,7 +85,7 @@ export const createSuggestionRenderer = (node, { itemConverter } = {}) => () =>
87
85
  suggestion.classList.remove("hidden", "hide");
88
86
  suggestion.innerHTML = "";
89
87
  items.forEach((rawItem, idx) => {
90
- const { label, id, help } = convertItem(rawItem);
88
+ const { label, id, help, avatarUrl, displayLabel } = convertItem(rawItem);
91
89
  const suggestionItem = document.createElement("button");
92
90
  suggestionItem.type = "button";
93
91
  suggestionItem.classList.add("editor-suggestions-item");
@@ -103,7 +101,19 @@ export const createSuggestionRenderer = (node, { itemConverter } = {}) => () =>
103
101
  if (help) {
104
102
  suggestionItem.disabled = true;
105
103
  }
106
- suggestionItem.textContent = label;
104
+
105
+ if (avatarUrl) {
106
+ const avatar = document.createElement("img");
107
+ avatar.classList.add("editor-suggestions-item-avatar");
108
+ avatar.src = avatarUrl;
109
+ avatar.alt = label;
110
+ suggestionItem.append(avatar);
111
+ }
112
+
113
+ const labelElement = document.createElement("span");
114
+ labelElement.classList.add("editor-suggestions-item-label");
115
+ labelElement.textContent = displayLabel || label;
116
+ suggestionItem.append(labelElement);
107
117
  suggestion.append(suggestionItem);
108
118
 
109
119
  suggestionItem.addEventListener("click", (ev) => {
@@ -29,9 +29,11 @@ const searchUsers = async (queryText) => {
29
29
  export default Mention.extend({
30
30
  addOptions() {
31
31
  const parentOptions = this.parent?.();
32
+ const searchPromptMessage = parentOptions?.searchPromptMessage || "Type to search participants";
32
33
 
33
34
  return {
34
35
  ...parentOptions,
36
+ searchPromptMessage,
35
37
  renderText({ node }) {
36
38
  // renderText is used to create the DOM representation
37
39
  const label = node.attrs.label ?? node.attrs.id;
@@ -42,16 +44,25 @@ export default Mention.extend({
42
44
  allowSpaces: true,
43
45
  items: async ({ query }) => {
44
46
  if (query.length < 2) {
45
- return [];
47
+ return [{ label: searchPromptMessage, help: true }];
46
48
  }
47
49
 
48
50
  const data = await searchUsers(query);
49
- const sorted = data.sort((user) => user.nickname.slice(1));
51
+ const sorted = data.sort((first, second) => first.nickname.localeCompare(second.nickname));
50
52
  return sorted.slice(0, 5);
51
53
  },
52
54
  render: createSuggestionRenderer(this, {
53
55
  itemConverter: (user) => {
54
- return { id: user.nickname, label: `${user.nickname} (${user.name})` }
56
+ if (user.help) {
57
+ return { label: user.label, help: true }
58
+ }
59
+
60
+ return {
61
+ id: user.nickname,
62
+ label: user.nickname,
63
+ displayLabel: `${user.nickname} (${user.name})`,
64
+ avatarUrl: user.avatarUrl
65
+ }
55
66
  }
56
67
  })
57
68
  }
@@ -87,5 +87,9 @@ export default function createEditor(container) {
87
87
  : editor.getHTML();
88
88
  });
89
89
 
90
+ editor.view.dom.addEventListener("move-cursor-to-end", () => {
91
+ editor.chain().focus("end").run();
92
+ });
93
+
90
94
  return editor;
91
95
  }
@@ -1,6 +1,6 @@
1
1
  /* global jest, global */
2
2
 
3
- import { createBasicEditor, updateContent } from "src/decidim/editor/test/helpers";
3
+ import { createBasicEditor, updateContent, sleep } from "src/decidim/editor/test/helpers";
4
4
 
5
5
  import Mention from "src/decidim/editor/extensions/mention";
6
6
 
@@ -87,8 +87,11 @@ describe("Mention", () => {
87
87
  const items = suggestions.querySelectorAll(".editor-suggestions-item");
88
88
  expect(items.length).toEqual(expectedTags.length);
89
89
  for (const item of items) {
90
- expect(expectedTags.includes(item.textContent)).toBe(true);
90
+ expect(expectedTags.includes(item.querySelector(".editor-suggestions-item-label").textContent)).toBe(true);
91
91
  }
92
+
93
+ expect(items[0].querySelector(".editor-suggestions-item-avatar")).toBeInstanceOf(HTMLImageElement);
94
+ expect(items[0].querySelector(".editor-suggestions-item-avatar").getAttribute("src")).toBe("/avatars/joannadoe.jpg");
92
95
  });
93
96
 
94
97
  it("does not display the suggestions when less than two characters are entered", async () => {
@@ -97,9 +100,12 @@ describe("Mention", () => {
97
100
 
98
101
  const suggestions = document.querySelector(".editor-suggestions");
99
102
  expect(suggestions).toBeInstanceOf(HTMLDivElement);
100
- expect(suggestions.childNodes.length).toBe(0);
101
- expect(suggestions.classList.contains("hidden")).toBe(true);
102
- expect(suggestions.classList.contains("hide")).toBe(true);
103
+ const items = suggestions.querySelectorAll(".editor-suggestions-item");
104
+ expect(items.length).toBe(1);
105
+ expect(items[0].textContent).toBe("Type to search participants");
106
+ expect(items[0].disabled).toBe(true);
107
+ expect(suggestions.classList.contains("hidden")).toBe(false);
108
+ expect(suggestions.classList.contains("hide")).toBe(false);
103
109
  });
104
110
 
105
111
  it("allows selecting a mention from the list by clicking it", async () => {
@@ -110,10 +116,10 @@ describe("Mention", () => {
110
116
  suggestions.querySelector(".editor-suggestions-item").click();
111
117
 
112
118
  expect(editorElement.innerHTML).toEqual(
113
- '<p><span data-suggestion="mention" data-id="@johndoe" data-label="@johndoe (John Doe)">@johndoe (John Doe)</span> </p>'
119
+ '<p><span data-suggestion="mention" data-id="@johndoe" data-label="@johndoe">@johndoe</span> </p>'
114
120
  );
115
121
  expect(normalizeHTML(editor.getHTML())).toEqual(
116
- '<p><span data-type="mention" data-id="@johndoe" data-label="@johndoe (John Doe)">@johndoe (John Doe)</span> </p>'
122
+ '<p><span data-type="mention" data-id="@johndoe" data-label="@johndoe">@johndoe</span> </p>'
117
123
  );
118
124
  });
119
125
 
@@ -124,10 +130,27 @@ describe("Mention", () => {
124
130
  editorElement.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
125
131
 
126
132
  expect(editorElement.innerHTML).toEqual(
127
- '<p><span data-suggestion="mention" data-id="@johndoe" data-label="@johndoe (John Doe)">@johndoe (John Doe)</span> </p>'
133
+ '<p><span data-suggestion="mention" data-id="@johndoe" data-label="@johndoe">@johndoe</span> </p>'
128
134
  );
129
135
  expect(normalizeHTML(editor.getHTML())).toEqual(
130
- '<p><span data-type="mention" data-id="@johndoe" data-label="@johndoe (John Doe)">@johndoe (John Doe)</span> </p>'
136
+ '<p><span data-type="mention" data-id="@johndoe" data-label="@johndoe">@johndoe</span> </p>'
131
137
  );
132
138
  });
139
+
140
+ it("allows selecting a mention when content changes after triggering", async () => {
141
+ editorElement.focus();
142
+ await updateContent(editorElement, "@joh", editor);
143
+
144
+ const suggestions = document.querySelector(".editor-suggestions");
145
+ expect(suggestions).toBeInstanceOf(HTMLDivElement);
146
+
147
+ editor.commands.setContent("<p>Some new content @joh</p>");
148
+ await sleep(50);
149
+
150
+ expect(() => {
151
+ suggestions.querySelector(".editor-suggestions-item").click();
152
+ }).not.toThrow();
153
+
154
+ expect(normalizeHTML(editor.getHTML())).toContain('data-type="mention"');
155
+ });
133
156
  });
@@ -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
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * This focuses an element
3
+ * @param {HTMLElement} element The container element to search for a focusable input or editor.
4
+ * @returns {void} Nothing
5
+ */
6
+ export default function autofocus(element) {
7
+ let node = element.querySelector(".editor .ProseMirror, input[type='text'], textarea")
8
+
9
+ if (!node) {
10
+ return;
11
+ }
12
+
13
+ node.dispatchEvent(new CustomEvent("move-cursor-to-end", { bubbles: true }));
14
+
15
+ if (typeof node.focus === "function") {
16
+ node.focus();
17
+ }
18
+
19
+ let value = node.value;
20
+ node.value = "";
21
+ node.value = value;
22
+ }
@@ -0,0 +1,171 @@
1
+ /* global jest */
2
+
3
+ import autofocus from "src/decidim/refactor/implementation/autofocus"
4
+
5
+ describe("autofocus", () => {
6
+ describe("when ProseMirror editor is present", () => {
7
+ it("dispatches move-cursor-to-end event on ProseMirror", () => {
8
+ const container = document.createElement("div");
9
+ const editor = document.createElement("div");
10
+ editor.className = "editor";
11
+ const prosemirror = document.createElement("div");
12
+ prosemirror.className = "ProseMirror";
13
+ editor.appendChild(prosemirror);
14
+ container.appendChild(editor);
15
+ document.body.appendChild(container);
16
+
17
+ const dispatchSpy = jest.spyOn(prosemirror, "dispatchEvent");
18
+
19
+ autofocus(container);
20
+
21
+ expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({
22
+ type: "move-cursor-to-end",
23
+ bubbles: true
24
+ }));
25
+ dispatchSpy.mockRestore();
26
+ container.remove();
27
+ });
28
+
29
+ it("calls focus on ProseMirror element", () => {
30
+ const container = document.createElement("div");
31
+ const editor = document.createElement("div");
32
+ editor.className = "editor";
33
+ const prosemirror = document.createElement("div");
34
+ prosemirror.className = "ProseMirror";
35
+ prosemirror.contentEditable = true;
36
+ const focusSpy = jest.spyOn(prosemirror, "focus");
37
+ editor.appendChild(prosemirror);
38
+ container.appendChild(editor);
39
+ document.body.appendChild(container);
40
+
41
+ autofocus(container);
42
+
43
+ expect(focusSpy).toHaveBeenCalled();
44
+ focusSpy.mockRestore();
45
+ container.remove();
46
+ });
47
+ });
48
+
49
+ describe("when input[type='text'] is present", () => {
50
+ it("focuses on the input element", () => {
51
+ const container = document.createElement("div");
52
+ const input = document.createElement("input");
53
+ input.type = "text";
54
+ input.value = "test value";
55
+ const focusSpy = jest.spyOn(input, "focus");
56
+ container.appendChild(input);
57
+ document.body.appendChild(container);
58
+
59
+ autofocus(container);
60
+
61
+ expect(focusSpy).toHaveBeenCalled();
62
+ expect(input.value).toBe("test value");
63
+ focusSpy.mockRestore();
64
+ container.remove();
65
+ });
66
+
67
+ it("clears and restores the input value", () => {
68
+ const container = document.createElement("div");
69
+ const input = document.createElement("input");
70
+ input.type = "text";
71
+ input.value = "original value";
72
+ container.appendChild(input);
73
+ document.body.appendChild(container);
74
+
75
+ autofocus(container);
76
+
77
+ expect(input.value).toBe("original value");
78
+ container.remove();
79
+ });
80
+ });
81
+
82
+ describe("when textarea is present", () => {
83
+ it("focuses on the textarea element", () => {
84
+ const container = document.createElement("div");
85
+ const textarea = document.createElement("textarea");
86
+ textarea.value = "textarea content";
87
+ const focusSpy = jest.spyOn(textarea, "focus");
88
+ container.appendChild(textarea);
89
+ document.body.appendChild(container);
90
+
91
+ autofocus(container);
92
+
93
+ expect(focusSpy).toHaveBeenCalled();
94
+ expect(textarea.value).toBe("textarea content");
95
+ focusSpy.mockRestore();
96
+ container.remove();
97
+ });
98
+
99
+ it("clears and restores the textarea value", () => {
100
+ const container = document.createElement("div");
101
+ const textarea = document.createElement("textarea");
102
+ textarea.value = "original content";
103
+ container.appendChild(textarea);
104
+ document.body.appendChild(container);
105
+
106
+ autofocus(container);
107
+
108
+ expect(textarea.value).toBe("original content");
109
+ container.remove();
110
+ });
111
+ });
112
+
113
+ describe("when no focusable element is present", () => {
114
+ it("does nothing and does not throw", () => {
115
+ const container = document.createElement("div");
116
+ container.innerHTML = "<p>No focusable element here</p>";
117
+ document.body.appendChild(container);
118
+
119
+ expect(() => autofocus(container)).not.toThrow();
120
+ container.remove();
121
+ });
122
+ });
123
+
124
+ describe("priority of elements", () => {
125
+ it("prefers ProseMirror over input", () => {
126
+ const container = document.createElement("div");
127
+ const editor = document.createElement("div");
128
+ editor.className = "editor";
129
+ const prosemirror = document.createElement("div");
130
+ prosemirror.className = "ProseMirror";
131
+ editor.appendChild(prosemirror);
132
+ const input = document.createElement("input");
133
+ input.type = "text";
134
+ editor.appendChild(input);
135
+ container.appendChild(editor);
136
+ document.body.appendChild(container);
137
+
138
+ const prosemirrorDispatchSpy = jest.spyOn(prosemirror, "dispatchEvent");
139
+ const inputFocusSpy = jest.spyOn(input, "focus");
140
+
141
+ autofocus(container);
142
+
143
+ expect(prosemirrorDispatchSpy).toHaveBeenCalled();
144
+ expect(inputFocusSpy).not.toHaveBeenCalled();
145
+ prosemirrorDispatchSpy.mockRestore();
146
+ inputFocusSpy.mockRestore();
147
+ container.remove();
148
+ });
149
+
150
+ it("prefers input over textarea", () => {
151
+ const container = document.createElement("div");
152
+ const input = document.createElement("input");
153
+ input.type = "text";
154
+ const textarea = document.createElement("textarea");
155
+ container.appendChild(input);
156
+ container.appendChild(textarea);
157
+ document.body.appendChild(container);
158
+
159
+ const inputFocusSpy = jest.spyOn(input, "focus");
160
+ const textareaFocusSpy = jest.spyOn(textarea, "focus");
161
+
162
+ autofocus(container);
163
+
164
+ expect(inputFocusSpy).toHaveBeenCalled();
165
+ expect(textareaFocusSpy).not.toHaveBeenCalled();
166
+ inputFocusSpy.mockRestore();
167
+ textareaFocusSpy.mockRestore();
168
+ container.remove();
169
+ });
170
+ });
171
+ });
@@ -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