decidim-core 0.32.0.rc2 → 0.32.0

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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/amendable/amend_button_card/show.erb +2 -2
  3. data/app/cells/decidim/author_cell.rb +0 -4
  4. data/app/cells/decidim/card_metadata_cell.rb +1 -1
  5. data/app/cells/decidim/content_blocks/cta_settings_form/show.erb +1 -1
  6. data/app/cells/decidim/content_blocks/hero_settings_form/show.erb +1 -1
  7. data/app/cells/decidim/content_blocks/highlighted_content_banner_cell.rb +1 -1
  8. data/app/cells/decidim/content_blocks/highlighted_content_banner_settings_form/show.erb +1 -1
  9. data/app/cells/decidim/content_blocks/highlighted_participatory_spaces_cell.rb +1 -1
  10. data/app/cells/decidim/content_blocks/participatory_space_hero_settings_form/show.erb +1 -1
  11. data/app/cells/decidim/content_blocks/static_page/section_settings_form/show.erb +3 -1
  12. data/app/cells/decidim/content_blocks/static_page/summary_settings_form/show.erb +3 -1
  13. data/app/cells/decidim/content_blocks/static_page/two_pane_section_settings_form/show.erb +4 -2
  14. data/app/cells/decidim/resource_types_filter/show.erb +2 -2
  15. data/app/commands/decidim/destroy_account.rb +12 -1
  16. data/app/commands/decidim/multiple_attachments_methods.rb +28 -27
  17. data/app/controllers/concerns/decidim/devise_controllers.rb +9 -0
  18. data/app/events/decidim/amendable/amendment_base_event.rb +7 -1
  19. data/app/jobs/decidim/process_inactive_participant_job.rb +0 -7
  20. data/app/mailers/decidim/delete_user_mailer.rb +14 -0
  21. data/app/mailers/decidim/participants_account_mailer.rb +0 -16
  22. data/app/models/decidim/moderation.rb +1 -1
  23. data/app/models/decidim/participatory_space/member.rb +1 -1
  24. data/app/models/decidim/user_base_entity.rb +17 -2
  25. data/app/models/decidim/user_moderation.rb +1 -1
  26. data/app/packs/src/decidim/controllers/breadcrumb_truncate/breadcrumb_truncate.test.js +230 -0
  27. data/app/packs/src/decidim/controllers/breadcrumb_truncate/controller.js +172 -0
  28. data/app/packs/src/decidim/controllers/form_validator/form_validator.js +6 -6
  29. data/app/packs/src/decidim/controllers/form_validator/form_validator.test.js +23 -1
  30. data/app/packs/src/decidim/controllers/main_menu/controller.js +33 -0
  31. data/app/packs/src/decidim/controllers/main_menu/main_menu.test.js +77 -0
  32. data/app/packs/src/decidim/controllers/mention/controller.js +296 -140
  33. data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +120 -457
  34. data/app/packs/src/decidim/controllers/multiple_mentions/controller.js +68 -32
  35. data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +30 -23
  36. data/app/packs/src/decidim/direct_uploads/upload_field.js +4 -4
  37. data/app/packs/src/decidim/direct_uploads/upload_modal.js +11 -7
  38. data/app/packs/src/decidim/editor/common/suggestion.js +3 -1
  39. data/app/packs/src/decidim/editor/extensions/indent/index.js +9 -0
  40. data/app/packs/src/decidim/geocoding/reverse_geocoding.js +15 -5
  41. data/app/packs/src/decidim/geocoding/reverse_geocoding.test.js +197 -0
  42. data/app/packs/src/decidim/index.js +4 -3
  43. data/app/packs/src/decidim/sw/sw.js +1 -1
  44. data/app/packs/src/decidim/utilities/text.js +6 -6
  45. data/app/packs/stylesheets/decidim/_conversations.scss +14 -0
  46. data/app/packs/stylesheets/decidim/_dropdown.scss +1 -1
  47. data/app/packs/stylesheets/decidim/_editor_suggestions.scss +49 -0
  48. data/app/packs/stylesheets/decidim/_floating_help.scss +1 -1
  49. data/app/packs/stylesheets/decidim/_header.scss +41 -8
  50. data/app/packs/stylesheets/decidim/_tom_select.scss +23 -0
  51. data/app/packs/stylesheets/decidim/application.scss +2 -0
  52. data/app/packs/stylesheets/decidim/editor.scss +2 -33
  53. data/app/packs/stylesheets/decidim/geocoding_addons.scss +10 -2
  54. data/app/presenters/decidim/menu_item_presenter.rb +9 -3
  55. data/app/presenters/decidim/stats_presenter.rb +1 -1
  56. data/app/uploaders/decidim/image_uploader.rb +1 -1
  57. data/app/views/decidim/delete_user_mailer/delete.html.erb +6 -0
  58. data/app/views/decidim/gamification/badges/index.html.erb +1 -1
  59. data/app/views/decidim/homepage/show.html.erb +1 -1
  60. data/app/views/decidim/last_activities/index.html.erb +1 -1
  61. data/app/views/decidim/messaging/conversations/_error_modal.html.erb +11 -19
  62. data/app/views/decidim/messaging/conversations/error.js.erb +12 -7
  63. data/app/views/decidim/newsletters/unsubscribe.html.erb +1 -1
  64. data/app/views/decidim/offline/show.html.erb +1 -1
  65. data/app/views/decidim/pages/index.html.erb +1 -1
  66. data/app/views/decidim/profiles/show.html.erb +1 -1
  67. data/app/views/decidim/shared/_resource_actions.html.erb +4 -4
  68. data/app/views/decidim/user_activities/index.html.erb +1 -1
  69. data/app/views/layouts/decidim/_wrapper.html.erb +2 -2
  70. data/app/views/layouts/decidim/header/_menu.html.erb +1 -1
  71. data/app/views/layouts/decidim/header/_menu_breadcrumb_desktop.html.erb +31 -4
  72. data/app/views/layouts/decidim/header/_menu_breadcrumb_mobile.html.erb +18 -13
  73. data/app/views/layouts/decidim/shared/_layout_center.html.erb +1 -1
  74. data/app/views/layouts/decidim/shared/_layout_item.html.erb +1 -1
  75. data/app/views/layouts/decidim/shared/_layout_two_col.html.erb +1 -1
  76. data/config/initializers/decidim_locale_aware_named_route_helper.rb +16 -0
  77. data/config/locales/ar.yml +0 -25
  78. data/config/locales/bg.yml +0 -25
  79. data/config/locales/ca-IT.yml +46 -35
  80. data/config/locales/ca.yml +46 -35
  81. data/config/locales/cs.yml +12 -34
  82. data/config/locales/de.yml +5 -46
  83. data/config/locales/el.yml +0 -22
  84. data/config/locales/en.yml +45 -34
  85. data/config/locales/es-MX.yml +45 -34
  86. data/config/locales/es-PY.yml +45 -34
  87. data/config/locales/es.yml +45 -34
  88. data/config/locales/eu.yml +85 -74
  89. data/config/locales/fi-plain.yml +46 -34
  90. data/config/locales/fi.yml +46 -34
  91. data/config/locales/fr-CA.yml +24 -30
  92. data/config/locales/fr.yml +24 -30
  93. data/config/locales/gl.yml +0 -22
  94. data/config/locales/hu.yml +0 -22
  95. data/config/locales/id-ID.yml +0 -21
  96. data/config/locales/is-IS.yml +0 -6
  97. data/config/locales/it.yml +1 -20
  98. data/config/locales/ja.yml +55 -44
  99. data/config/locales/lb.yml +0 -23
  100. data/config/locales/lt.yml +0 -24
  101. data/config/locales/lv.yml +0 -21
  102. data/config/locales/nl.yml +0 -23
  103. data/config/locales/no.yml +0 -23
  104. data/config/locales/pl.yml +2 -27
  105. data/config/locales/pt-BR.yml +2 -40
  106. data/config/locales/pt.yml +0 -23
  107. data/config/locales/ro-RO.yml +1 -32
  108. data/config/locales/ru.yml +0 -8
  109. data/config/locales/sk.yml +1 -43
  110. data/config/locales/sv.yml +6 -40
  111. data/config/locales/tr-TR.yml +0 -24
  112. data/config/locales/uk.yml +0 -6
  113. data/config/locales/zh-CN.yml +0 -21
  114. data/config/locales/zh-TW.yml +0 -24
  115. data/decidim-core.gemspec +1 -1
  116. data/lib/decidim/api/functions/user_entity_list.rb +2 -0
  117. data/lib/decidim/attachment_attributes.rb +58 -9
  118. data/lib/decidim/command.rb +1 -1
  119. data/lib/decidim/content_renderers/base_renderer.rb +112 -0
  120. data/lib/decidim/content_renderers/blob_renderer.rb +4 -7
  121. data/lib/decidim/content_renderers/mention_resource_renderer.rb +10 -6
  122. data/lib/decidim/content_renderers/resource_renderer.rb +16 -7
  123. data/lib/decidim/content_renderers/user_renderer.rb +11 -9
  124. data/lib/decidim/core/content_blocks/registry_manager.rb +4 -4
  125. data/lib/decidim/core/engine.rb +8 -0
  126. data/lib/decidim/core/test/factories.rb +3 -0
  127. data/lib/decidim/core/test/shared_examples/admin_resource_gallery_examples.rb +10 -10
  128. data/lib/decidim/core/test/shared_examples/amendable/amendment_event_examples.rb +2 -2
  129. data/lib/decidim/core/test/shared_examples/amendable/amendment_promoted_event_examples.rb +3 -3
  130. data/lib/decidim/core/test/shared_examples/comments_examples.rb +6 -6
  131. data/lib/decidim/core/test/shared_examples/has_space_in_mcell_examples.rb +2 -1
  132. data/lib/decidim/core/test/shared_examples/resource_liked_event_examples.rb +3 -3
  133. data/lib/decidim/core/version.rb +1 -1
  134. data/lib/decidim/map/autocomplete.rb +4 -3
  135. data/lib/decidim/searchable.rb +5 -0
  136. data/lib/decidim/view_model.rb +1 -1
  137. data/lib/tasks/decidim_mailers_tasks.rake +31 -9
  138. metadata +13 -9
  139. data/app/commands/decidim/gallery_methods.rb +0 -107
  140. data/app/packs/src/decidim/vendor/tribute.js +0 -1890
  141. data/app/packs/stylesheets/decidim/_tribute.scss +0 -36
  142. data/app/views/decidim/participants_account_mailer/removal_notification.html.erb +0 -11
@@ -0,0 +1,172 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const ELLIPSIS = "..."
4
+ const PREFERRED_MIN_WIDTH = 96
5
+ const ABSOLUTE_MIN_WIDTH = 24
6
+
7
+ export default class extends Controller {
8
+ static get targets() {
9
+ return ["item", "text"]
10
+ }
11
+
12
+ connect() {
13
+ this.originalTexts = this.textTargets.map((target) => target.textContent.trim())
14
+ this.refresh = this.refresh.bind(this)
15
+
16
+ this.resizeObserver = new ResizeObserver(() => this.refresh())
17
+ this.resizeObserver.observe(this.element)
18
+ window.addEventListener("resize", this.refresh)
19
+
20
+ this.refresh()
21
+ }
22
+
23
+ disconnect() {
24
+ this.resizeObserver?.disconnect()
25
+ window.removeEventListener("resize", this.refresh)
26
+ }
27
+
28
+ refresh() {
29
+ if (!this.hasTextTarget) {
30
+ return
31
+ }
32
+
33
+ if (this.hasItemTarget) {
34
+ this.refreshGroup()
35
+ return
36
+ }
37
+
38
+ this.truncateItem(this.element, this.textTarget, this.originalTexts[0])
39
+ }
40
+
41
+ refreshGroup() {
42
+ this.itemTargets.forEach((item, index) => this.resetItem(item, this.textTargets[index], this.originalTexts[index]))
43
+
44
+ if (this.element.clientWidth <= 0) {
45
+ return
46
+ }
47
+
48
+ if (!this.isOverflowing(this.element)) {
49
+ return
50
+ }
51
+
52
+ const candidates = this.itemTargets.map((item, index) => ({
53
+ item,
54
+ text: this.textTargets[index],
55
+ originalText: this.originalTexts[index],
56
+ width: item.scrollWidth
57
+ })).sort((left, right) => right.width - left.width)
58
+
59
+ candidates.forEach((candidate) => {
60
+ if (!this.isOverflowing(this.element)) {
61
+ return
62
+ }
63
+
64
+ this.shrinkCandidate(candidate, PREFERRED_MIN_WIDTH)
65
+
66
+ if (this.isOverflowing(this.element)) {
67
+ this.shrinkCandidate(candidate, ABSOLUTE_MIN_WIDTH)
68
+ }
69
+ })
70
+ }
71
+
72
+ shrinkCandidate(candidate, minimumWidth) {
73
+ const overflow = this.element.scrollWidth - this.element.clientWidth
74
+
75
+ if (overflow <= 0) {
76
+ return
77
+ }
78
+
79
+ const minWidth = Math.min(candidate.width, minimumWidth)
80
+ const shrinkableWidth = candidate.width - minWidth
81
+
82
+ if (shrinkableWidth <= 0) {
83
+ return
84
+ }
85
+
86
+ const reduction = Math.min(overflow, shrinkableWidth)
87
+ const nextWidth = candidate.width - reduction
88
+
89
+ candidate.item.style.flex = `0 1 ${nextWidth}px`
90
+ candidate.item.style.maxWidth = `${nextWidth}px`
91
+ candidate.item.style.minWidth = "0"
92
+
93
+ this.truncateItem(candidate.item, candidate.text, candidate.originalText)
94
+ candidate.width = nextWidth
95
+ }
96
+
97
+ resetItem(item, text, originalText) {
98
+ item.style.removeProperty("flex")
99
+ item.style.removeProperty("max-width")
100
+ item.style.removeProperty("min-width")
101
+ item.removeAttribute("title")
102
+ text.textContent = originalText
103
+ }
104
+
105
+ truncateItem(item, text, originalText) {
106
+ if (item.clientWidth <= 0) {
107
+ return
108
+ }
109
+
110
+ text.textContent = originalText
111
+
112
+ if (!this.isOverflowing(item)) {
113
+ item.removeAttribute("title")
114
+ return
115
+ }
116
+
117
+ const words = originalText.split(/\s+/).filter(Boolean)
118
+ let visibleText = ""
119
+
120
+ for (let index = 0; index < words.length; index += 1) {
121
+ let nextText = words[index]
122
+
123
+ if (visibleText) {
124
+ nextText = `${visibleText} ${words[index]}`
125
+ }
126
+
127
+ text.textContent = `${nextText}${ELLIPSIS}`
128
+
129
+ if (this.isOverflowing(item)) {
130
+ break
131
+ }
132
+
133
+ visibleText = nextText
134
+ }
135
+
136
+ if (!visibleText) {
137
+ visibleText = this.truncateSingleWord(item, text, words[0] || "")
138
+ }
139
+
140
+ if (visibleText === originalText) {
141
+ text.textContent = visibleText
142
+ } else {
143
+ text.textContent = `${visibleText}${ELLIPSIS}`
144
+ }
145
+
146
+ item.setAttribute("title", originalText)
147
+ }
148
+
149
+ truncateSingleWord(item, text, word) {
150
+ let visibleText = ""
151
+
152
+ for (let index = 0; index < word.length; index += 1) {
153
+ text.textContent = `${visibleText}${word[index]}${ELLIPSIS}`
154
+
155
+ if (this.isOverflowing(item)) {
156
+ break
157
+ }
158
+
159
+ visibleText += word[index]
160
+ }
161
+
162
+ if (!visibleText && word) {
163
+ return word[0]
164
+ }
165
+
166
+ return visibleText
167
+ }
168
+
169
+ isOverflowing(element) {
170
+ return element.scrollWidth > element.clientWidth
171
+ }
172
+ }
@@ -419,13 +419,13 @@ class FormValidator {
419
419
  return true;
420
420
  }
421
421
 
422
- checkboxGroup.forEach((checkboxElement) => {
423
- if (isValid) {
424
- this.removeInputErrorClasses(checkboxElement);
425
- } else {
422
+ if (isValid) {
423
+ this.removeCheckboxGroupErrorClasses(groupName);
424
+ } else {
425
+ checkboxGroup.forEach((checkboxElement) => {
426
426
  this.addInputErrorClasses(checkboxElement, ["required"]);
427
- }
428
- });
427
+ });
428
+ }
429
429
 
430
430
  return isValid;
431
431
  }
@@ -1,4 +1,4 @@
1
- /* eslint max-lines: ["error", 690] */
1
+ /* eslint max-lines: ["error", 712] */
2
2
  /* global jest */
3
3
  /**
4
4
  * @jest-environment jsdom
@@ -253,6 +253,28 @@ describe("FormValidator", () => {
253
253
  const isValidTwo = validatorInstance.validateCheckboxGroup("multiCheckboxes");
254
254
  expect(isValidTwo).toBe(true);
255
255
  });
256
+
257
+ it("should remove error classes once for the group, not per checkbox", () => {
258
+ formElement.innerHTML = `
259
+ <fieldset>
260
+ <input type="checkbox" id="cb-1" name="largeGroup" value="1" required>
261
+ <input type="checkbox" id="cb-2" name="largeGroup" value="2" required>
262
+ <input type="checkbox" id="cb-3" name="largeGroup" value="3" required>
263
+ </fieldset>
264
+ <button type="submit">Submit</button>
265
+ `;
266
+ validatorInstance = new FormValidator(formElement);
267
+ formElement.querySelector("#cb-1").checked = true;
268
+
269
+ const removeGroupSpy = jest.spyOn(validatorInstance, "removeCheckboxGroupErrorClasses");
270
+ const removeInputSpy = jest.spyOn(validatorInstance, "removeInputErrorClasses");
271
+
272
+ const isValid = validatorInstance.validateCheckboxGroup("largeGroup");
273
+
274
+ expect(isValid).toBe(true);
275
+ expect(removeGroupSpy).toHaveBeenCalledTimes(1);
276
+ expect(removeInputSpy).not.toHaveBeenCalled();
277
+ });
256
278
  });
257
279
 
258
280
  describe("Custom Validators", () => {
@@ -2,6 +2,14 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  const OPEN_DELAY_MS = 50
4
4
 
5
+ const FOCUSABLE_SELECTORS = "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1'])"
6
+
7
+ const getFocusableElements = (container) => {
8
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS)).filter(
9
+ (el) => el.offsetParent !== null
10
+ );
11
+ }
12
+
5
13
  /**
6
14
  * Main menu dropdown controller and traps page scroll while the menu is open.
7
15
  *
@@ -24,6 +32,7 @@ export default class extends Controller {
24
32
  this.handleButtonClick = this.handleButtonClick.bind(this)
25
33
  this.handleKeydown = this.handleKeydown.bind(this)
26
34
  this.handleCloseButtonClick = this.handleCloseButtonClick.bind(this)
35
+ this.focusTrapHandler = this.focusTrapHandler.bind(this)
27
36
 
28
37
  this.menuButton.addEventListener("click", this.handleButtonClick)
29
38
  this.menuContainer.addEventListener("click", this.handleContainerClick)
@@ -96,6 +105,23 @@ export default class extends Controller {
96
105
  return this.menuContainer.getAttribute("aria-hidden") === "true"
97
106
  }
98
107
 
108
+ focusTrapHandler(event) {
109
+ if (event.key !== "Tab") {
110
+ return;
111
+ }
112
+ const focusable = getFocusableElements(this.menuContainer);
113
+ if (focusable.length === 0) {
114
+ return;
115
+ }
116
+ if (event.shiftKey && document.activeElement === focusable[0]) {
117
+ event.preventDefault();
118
+ focusable[focusable.length - 1].focus({ preventScroll: true });
119
+ } else if (!event.shiftKey && document.activeElement === focusable[focusable.length - 1]) {
120
+ event.preventDefault();
121
+ focusable[0].focus({ preventScroll: true });
122
+ }
123
+ }
124
+
99
125
  openMenu() {
100
126
  if (typeof this.previousBodyOverflow === "undefined") {
101
127
  this.previousBodyOverflow = document.body.style.overflow;
@@ -104,9 +130,16 @@ export default class extends Controller {
104
130
  this.element.setAttribute("aria-expanded", "true")
105
131
  this.menuContainer.setAttribute("aria-hidden", "false")
106
132
  this.menuContainer.setAttribute("aria-modal", "true")
133
+ this.menuContainer.addEventListener("keydown", this.focusTrapHandler)
134
+
135
+ const focusable = getFocusableElements(this.menuContainer);
136
+ if (focusable.length > 0) {
137
+ focusable[0].focus({ preventScroll: true });
138
+ }
107
139
  }
108
140
 
109
141
  closeMenu() {
142
+ this.menuContainer.removeEventListener("keydown", this.focusTrapHandler)
110
143
  document.body.style.overflow = this.previousBodyOverflow ?? ""
111
144
  this.element.setAttribute("aria-expanded", "false")
112
145
  this.menuContainer.setAttribute("aria-hidden", "true")
@@ -20,6 +20,8 @@ describe("MainMenuController", () => {
20
20
  Menu
21
21
  </button>
22
22
  <div id="main-menu-container" aria-hidden="true">
23
+ <a href="/link">Link</a>
24
+ <button>Action</button>
23
25
  <div id="main-menu-item"></div>
24
26
  </div>
25
27
  <button id="main-menu-close">Close</button>
@@ -165,6 +167,81 @@ describe("MainMenuController", () => {
165
167
  })
166
168
  })
167
169
 
170
+ describe("focusTrapHandler", () => {
171
+ beforeEach(() => {
172
+ jest.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(menuContainer)
173
+ controller.openMenu()
174
+ })
175
+
176
+ afterEach(() => {
177
+ controller.closeMenu()
178
+ })
179
+
180
+ it("focuses the first focusable element when menu opens", () => {
181
+ const focusable = menuContainer.querySelectorAll("a[href], button:not([disabled])")
182
+ const firstEl = focusable[0]
183
+ expect(document.activeElement).toBe(firstEl)
184
+ })
185
+
186
+ it("cycles focus to first element when Tab is pressed on last focusable element", () => {
187
+ const focusable = menuContainer.querySelectorAll("a[href], button:not([disabled])")
188
+ expect(focusable.length).toBeGreaterThan(1)
189
+ const lastEl = focusable[focusable.length - 1]
190
+ const firstEl = focusable[0]
191
+
192
+ lastEl.focus()
193
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true })
194
+ jest.spyOn(event, "preventDefault")
195
+ jest.spyOn(firstEl, "focus")
196
+
197
+ menuContainer.dispatchEvent(event)
198
+
199
+ expect(event.preventDefault).toHaveBeenCalled()
200
+ expect(firstEl.focus).toHaveBeenCalledWith({ preventScroll: true })
201
+ })
202
+
203
+ it("cycles focus to last element when Shift+Tab is pressed on first focusable element", () => {
204
+ const focusable = menuContainer.querySelectorAll("a[href], button:not([disabled])")
205
+ expect(focusable.length).toBeGreaterThan(1)
206
+ const firstEl = focusable[0]
207
+ const lastEl = focusable[focusable.length - 1]
208
+
209
+ firstEl.focus()
210
+ const event = new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true })
211
+ jest.spyOn(event, "preventDefault")
212
+ jest.spyOn(lastEl, "focus")
213
+
214
+ menuContainer.dispatchEvent(event)
215
+
216
+ expect(event.preventDefault).toHaveBeenCalled()
217
+ expect(lastEl.focus).toHaveBeenCalledWith({ preventScroll: true })
218
+ })
219
+
220
+ it("does nothing when a non-Tab key is pressed", () => {
221
+ const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
222
+ jest.spyOn(event, "preventDefault")
223
+
224
+ menuContainer.dispatchEvent(event)
225
+
226
+ expect(event.preventDefault).not.toHaveBeenCalled()
227
+ })
228
+
229
+ it("does nothing when there are no focusable elements", () => {
230
+ controller.closeMenu()
231
+ menuContainer.innerHTML = ""
232
+ controller.openMenu()
233
+
234
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true })
235
+ expect(() => menuContainer.dispatchEvent(event)).not.toThrow()
236
+ })
237
+
238
+ it("removes the keydown handler when menu is closed", () => {
239
+ const spy = jest.spyOn(menuContainer, "removeEventListener")
240
+ controller.closeMenu()
241
+ expect(spy).toHaveBeenCalledWith("keydown", controller.focusTrapHandler)
242
+ })
243
+ })
244
+
168
245
  describe("disconnect", () => {
169
246
  it("removes listeners and closes the menu if open", () => {
170
247
  const buttonSpy = jest.spyOn(menuButton, "removeEventListener")