decidim-core 0.32.0.rc3 → 0.32.1
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.
- checksums.yaml +4 -4
- data/app/cells/decidim/author_cell.rb +0 -4
- data/app/cells/decidim/comments_button_cell.rb +9 -1
- data/app/cells/decidim/content_blocks/highlighted_content_banner_cell.rb +1 -1
- data/app/cells/decidim/content_blocks/highlighted_participatory_spaces_cell.rb +1 -1
- data/app/cells/decidim/data_consent/category.erb +20 -33
- data/app/cells/decidim/footer_topics/show.erb +5 -8
- data/app/cells/decidim/share_widget/qr_code_modal.erb +1 -1
- data/app/commands/decidim/destroy_account.rb +12 -1
- data/app/commands/decidim/multiple_attachments_methods.rb +28 -27
- data/app/helpers/decidim/menu_helper.rb +0 -2
- data/app/jobs/decidim/process_inactive_participant_job.rb +0 -7
- data/app/mailers/decidim/delete_user_mailer.rb +14 -0
- data/app/mailers/decidim/participants_account_mailer.rb +0 -16
- data/app/models/decidim/user.rb +2 -2
- data/app/packs/src/decidim/controllers/accordion/accordion.test.js +196 -42
- data/app/packs/src/decidim/controllers/accordion/controller.js +93 -17
- data/app/packs/src/decidim/controllers/dropdown/controller.js +6 -0
- data/app/packs/src/decidim/controllers/dropdown/dropdown.test.js +28 -0
- data/app/packs/src/decidim/controllers/main_menu/controller.js +37 -0
- data/app/packs/src/decidim/controllers/main_menu/main_menu.test.js +100 -0
- data/app/packs/src/decidim/controllers/mention/controller.js +296 -140
- data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +120 -457
- data/app/packs/src/decidim/controllers/multiple_mentions/controller.js +68 -32
- data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +30 -23
- data/app/packs/src/decidim/controllers/password_toggler/controller.js +22 -1
- data/app/packs/src/decidim/controllers/password_toggler/password_toggler.test.js +11 -20
- data/app/packs/src/decidim/direct_uploads/upload_modal.js +2 -1
- data/app/packs/src/decidim/editor/common/suggestion.js +3 -1
- data/app/packs/src/decidim/editor/extensions/indent/index.js +9 -0
- data/app/packs/src/decidim/geocoding/reverse_geocoding.js +15 -5
- data/app/packs/src/decidim/geocoding/reverse_geocoding.test.js +197 -0
- data/app/packs/src/decidim/index.js +2 -2
- data/app/packs/src/decidim/map/controller.js +10 -1
- data/app/packs/src/decidim/refactor/moved/focus_guard.js +12 -12
- data/app/packs/src/decidim/refactor/moved/focus_guard.test.js +136 -0
- data/app/packs/stylesheets/decidim/_cards.scss +1 -1
- data/app/packs/stylesheets/decidim/_conversations.scss +14 -0
- data/app/packs/stylesheets/decidim/_cookies.scss +13 -5
- data/app/packs/stylesheets/decidim/_documents.scss +1 -1
- data/app/packs/stylesheets/decidim/_dropdown.scss +1 -1
- data/app/packs/stylesheets/decidim/_editor_suggestions.scss +49 -0
- data/app/packs/stylesheets/decidim/_footer.scss +33 -1
- data/app/packs/stylesheets/decidim/_header.scss +12 -8
- data/app/packs/stylesheets/decidim/_participatory_spaces.scss +1 -1
- data/app/packs/stylesheets/decidim/_tom_select.scss +23 -0
- data/app/packs/stylesheets/decidim/application.scss +2 -0
- data/app/packs/stylesheets/decidim/editor.scss +2 -33
- data/app/packs/stylesheets/decidim/geocoding_addons.scss +10 -2
- data/app/presenters/decidim/footer_menu_presenter.rb +2 -4
- data/app/resolvers/decidim/meta_image_url_resolver.rb +7 -0
- data/app/uploaders/decidim/image_uploader.rb +1 -1
- data/app/views/decidim/delete_user_mailer/delete.html.erb +6 -0
- data/app/views/decidim/messaging/conversations/_error_modal.html.erb +11 -19
- data/app/views/decidim/messaging/conversations/error.js.erb +12 -7
- data/app/views/decidim/searches/_filters.html.erb +1 -1
- data/app/views/layouts/decidim/footer/_main.html.erb +1 -1
- data/app/views/layouts/decidim/footer/_main_links.html.erb +56 -28
- data/app/views/layouts/decidim/header/_menu_breadcrumb_mobile.html.erb +2 -1
- data/config/locales/ar.yml +0 -2
- data/config/locales/bg.yml +0 -2
- data/config/locales/ca-IT.yml +27 -10
- data/config/locales/ca.yml +27 -10
- data/config/locales/cs.yml +26 -9
- data/config/locales/de.yml +4 -13
- data/config/locales/el.yml +0 -1
- data/config/locales/en.yml +26 -9
- data/config/locales/es-MX.yml +26 -9
- data/config/locales/es-PY.yml +26 -9
- data/config/locales/es.yml +26 -9
- data/config/locales/eu.yml +41 -10
- data/config/locales/fi-plain.yml +25 -8
- data/config/locales/fi.yml +25 -8
- data/config/locales/fr-CA.yml +30 -9
- data/config/locales/fr.yml +30 -9
- data/config/locales/hu.yml +0 -2
- data/config/locales/it.yml +0 -2
- data/config/locales/ja.yml +84 -19
- data/config/locales/lb.yml +0 -2
- data/config/locales/lt.yml +0 -2
- data/config/locales/nl.yml +0 -2
- data/config/locales/no.yml +0 -2
- data/config/locales/pl.yml +4 -4
- data/config/locales/pt-BR.yml +2 -11
- data/config/locales/pt.yml +0 -2
- data/config/locales/ro-RO.yml +1 -10
- data/config/locales/sk.yml +1 -10
- data/config/locales/sv.yml +0 -9
- data/config/locales/tr-TR.yml +0 -2
- data/config/locales/zh-CN.yml +0 -2
- data/config/locales/zh-TW.yml +0 -2
- data/db/migrate/20250217192438_convert_user_groups_into_users.rb +3 -2
- data/decidim-core.gemspec +4 -1
- data/lib/decidim/attachment_attributes.rb +58 -9
- data/lib/decidim/command.rb +1 -1
- data/lib/decidim/core/content_blocks/registry_manager.rb +4 -4
- data/lib/decidim/core/engine.rb +8 -0
- data/lib/decidim/core/test/factories.rb +3 -0
- data/lib/decidim/core/test/shared_examples/access_mode_restricted_participatory_spaces.rb +8 -5
- data/lib/decidim/core/test/shared_examples/access_mode_transparent_participatory_spaces.rb +14 -11
- data/lib/decidim/core/test/shared_examples/admin_resource_gallery_examples.rb +10 -10
- data/lib/decidim/core/test/shared_examples/comments_examples.rb +32 -6
- data/lib/decidim/core/version.rb +1 -1
- data/lib/decidim/core.rb +1 -1
- data/lib/decidim/map/autocomplete.rb +4 -3
- data/lib/decidim/map/dynamic_map.rb +3 -1
- data/lib/decidim/searchable.rb +5 -0
- data/lib/decidim/view_model.rb +1 -1
- data/lib/tasks/decidim_mailers_tasks.rake +31 -9
- metadata +31 -9
- data/app/commands/decidim/gallery_methods.rb +0 -107
- data/app/packs/src/decidim/vendor/tribute.js +0 -1890
- data/app/packs/stylesheets/decidim/_tribute.scss +0 -36
- data/app/views/decidim/participants_account_mailer/removal_notification.html.erb +0 -11
|
@@ -1,36 +1,44 @@
|
|
|
1
1
|
/* global jest */
|
|
2
2
|
|
|
3
3
|
import AccordionController from "src/decidim/controllers/accordion/controller";
|
|
4
|
+
import { screens } from "tailwindcss/defaultTheme"
|
|
4
5
|
|
|
5
6
|
jest.mock("a11y-accordion-component", () => ({
|
|
6
7
|
render: jest.fn(),
|
|
7
8
|
destroy: jest.fn()
|
|
8
9
|
}));
|
|
9
10
|
|
|
11
|
+
const createController = (controllerElement) => {
|
|
12
|
+
const instance = Object.create(AccordionController.prototype);
|
|
13
|
+
Reflect.defineProperty(instance, "element", {
|
|
14
|
+
get: () => controllerElement,
|
|
15
|
+
configurable: true
|
|
16
|
+
});
|
|
17
|
+
return instance;
|
|
18
|
+
};
|
|
19
|
+
|
|
10
20
|
describe("AccordionController", () => {
|
|
11
21
|
let controller = null;
|
|
12
22
|
let accordionElement = null;
|
|
13
23
|
let panel1 = null;
|
|
14
24
|
let panel2 = null;
|
|
15
25
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
const mockMatchMedia = (matchesOrPredicate = false) => {
|
|
27
|
+
window.matchMedia = jest.fn().mockImplementation((query) => {
|
|
28
|
+
const matches = typeof matchesOrPredicate === "function"
|
|
29
|
+
? matchesOrPredicate(query)
|
|
30
|
+
: matchesOrPredicate;
|
|
31
|
+
return {
|
|
32
|
+
matches,
|
|
33
|
+
media: query,
|
|
34
|
+
addEventListener: jest.fn(),
|
|
35
|
+
removeEventListener: jest.fn()
|
|
36
|
+
};
|
|
22
37
|
});
|
|
23
|
-
return instance;
|
|
24
38
|
};
|
|
25
39
|
|
|
26
40
|
beforeEach(() => {
|
|
27
|
-
|
|
28
|
-
matches: false,
|
|
29
|
-
media: query,
|
|
30
|
-
addListener: jest.fn(),
|
|
31
|
-
removeListener: jest.fn()
|
|
32
|
-
}));
|
|
33
|
-
|
|
41
|
+
mockMatchMedia(false);
|
|
34
42
|
document.body.innerHTML = `
|
|
35
43
|
<div id="test-accordion" data-controller="accordion">
|
|
36
44
|
<button id="trigger-1" data-controls="panel-1">Trigger 1</button>
|
|
@@ -39,11 +47,9 @@ describe("AccordionController", () => {
|
|
|
39
47
|
<div id="panel-2">Panel 2 Content</div>
|
|
40
48
|
</div>
|
|
41
49
|
`;
|
|
42
|
-
|
|
43
50
|
accordionElement = document.getElementById("test-accordion");
|
|
44
51
|
panel1 = document.getElementById("panel-1");
|
|
45
52
|
panel2 = document.getElementById("panel-2");
|
|
46
|
-
|
|
47
53
|
controller = createController(accordionElement);
|
|
48
54
|
});
|
|
49
55
|
|
|
@@ -56,10 +62,8 @@ describe("AccordionController", () => {
|
|
|
56
62
|
it("changes role from region to group when data-panel-role is group", () => {
|
|
57
63
|
panel1.setAttribute("role", "region");
|
|
58
64
|
panel2.setAttribute("role", "region");
|
|
59
|
-
|
|
60
65
|
accordionElement.dataset.panelRole = "group";
|
|
61
66
|
controller.fixPanelRole();
|
|
62
|
-
|
|
63
67
|
expect(panel1.getAttribute("role")).toBe("group");
|
|
64
68
|
expect(panel2.getAttribute("role")).toBe("group");
|
|
65
69
|
});
|
|
@@ -67,52 +71,202 @@ describe("AccordionController", () => {
|
|
|
67
71
|
it("removes role attribute when data-panel-role is none", () => {
|
|
68
72
|
panel1.setAttribute("role", "region");
|
|
69
73
|
panel2.setAttribute("role", "region");
|
|
70
|
-
|
|
71
74
|
accordionElement.dataset.panelRole = "none";
|
|
72
75
|
controller.fixPanelRole();
|
|
73
|
-
|
|
74
76
|
expect(panel1.getAttribute("role")).toBeNull();
|
|
75
77
|
expect(panel2.getAttribute("role")).toBeNull();
|
|
76
78
|
});
|
|
77
79
|
|
|
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", () => {
|
|
80
|
+
it("does nothing when data-panel-role is not set or empty", () => {
|
|
90
81
|
panel1.setAttribute("role", "region");
|
|
91
|
-
|
|
92
|
-
accordionElement.dataset.panelRole = "";
|
|
93
82
|
controller.fixPanelRole();
|
|
94
|
-
|
|
95
83
|
expect(panel1.getAttribute("role")).toBe("region");
|
|
96
84
|
});
|
|
97
85
|
|
|
98
86
|
it("sets custom role value when data-panel-role is set", () => {
|
|
99
87
|
panel1.setAttribute("role", "region");
|
|
100
|
-
|
|
101
88
|
accordionElement.dataset.panelRole = "navigation";
|
|
102
89
|
controller.fixPanelRole();
|
|
103
|
-
|
|
104
90
|
expect(panel1.getAttribute("role")).toBe("navigation");
|
|
105
91
|
});
|
|
106
92
|
|
|
107
93
|
it("handles nonexistent panels gracefully", () => {
|
|
108
94
|
accordionElement.dataset.panelRole = "group";
|
|
95
|
+
const trigger = document.createElement("button");
|
|
96
|
+
trigger.dataset.controls = "nonexistent-panel";
|
|
97
|
+
accordionElement.appendChild(trigger);
|
|
98
|
+
expect(() => controller.fixPanelRole()).not.toThrow();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
109
101
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
102
|
+
describe("_applyBreakpointOpenAttributes", () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
controller.initialize();
|
|
105
|
+
});
|
|
113
106
|
|
|
114
|
-
|
|
107
|
+
it("removes data-open when no breakpoint matches and no original exists", () => {
|
|
108
|
+
accordionElement.innerHTML = `
|
|
109
|
+
<button data-controls="panel-1" data-open-lg="true">Trigger</button>
|
|
110
|
+
<div id="panel-1">Panel 1</div>
|
|
111
|
+
`;
|
|
112
|
+
controller._applyBreakpointOpenAttributes();
|
|
113
|
+
expect(accordionElement.querySelector("[data-controls]").hasAttribute("data-open")).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("sets data-open from breakpoint attribute when viewport matches", () => {
|
|
117
|
+
mockMatchMedia((query) => query === `(min-width: ${screens.lg})`);
|
|
118
|
+
accordionElement.innerHTML = `
|
|
119
|
+
<button data-controls="panel-1" data-open-lg="true">Trigger</button>
|
|
120
|
+
<div id="panel-1">Panel 1</div>
|
|
121
|
+
`;
|
|
122
|
+
controller.initialize();
|
|
123
|
+
controller._applyBreakpointOpenAttributes();
|
|
124
|
+
expect(accordionElement.querySelector("[data-controls]").getAttribute("data-open")).toBe("true");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("restores original data-open when breakpoint no longer matches", () => {
|
|
128
|
+
const lgQuery = `(min-width: ${screens.lg})`;
|
|
129
|
+
accordionElement.innerHTML = `
|
|
130
|
+
<button data-controls="panel-1" data-open="false" data-open-lg="true">Trigger</button>
|
|
131
|
+
<div id="panel-1">Panel 1</div>
|
|
132
|
+
`;
|
|
133
|
+
const trigger = accordionElement.querySelector("[data-controls]");
|
|
134
|
+
controller.initialize();
|
|
135
|
+
|
|
136
|
+
mockMatchMedia((query) => query === lgQuery);
|
|
137
|
+
controller._applyBreakpointOpenAttributes();
|
|
138
|
+
expect(trigger.getAttribute("data-open")).toBe("true");
|
|
139
|
+
|
|
140
|
+
mockMatchMedia(() => false);
|
|
141
|
+
controller._applyBreakpointOpenAttributes();
|
|
142
|
+
expect(trigger.getAttribute("data-open")).toBe("false");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("removes data-open when breakpoint stops matching and no original existed", () => {
|
|
146
|
+
const lgQuery = `(min-width: ${screens.lg})`;
|
|
147
|
+
accordionElement.innerHTML = `
|
|
148
|
+
<button data-controls="panel-1" data-open-lg="true">Trigger</button>
|
|
149
|
+
<div id="panel-1">Panel 1</div>
|
|
150
|
+
`;
|
|
151
|
+
const trigger = accordionElement.querySelector("[data-controls]");
|
|
152
|
+
controller.initialize();
|
|
153
|
+
|
|
154
|
+
mockMatchMedia((query) => query === lgQuery);
|
|
155
|
+
controller._applyBreakpointOpenAttributes();
|
|
156
|
+
expect(trigger.getAttribute("data-open")).toBe("true");
|
|
157
|
+
|
|
158
|
+
mockMatchMedia(() => false);
|
|
159
|
+
controller._applyBreakpointOpenAttributes();
|
|
160
|
+
expect(trigger.hasAttribute("data-open")).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("applies the last matching breakpoint when multiple match", () => {
|
|
164
|
+
mockMatchMedia((query) =>
|
|
165
|
+
query === `(min-width: ${screens.sm})` || query === `(min-width: ${screens.lg})`
|
|
166
|
+
);
|
|
167
|
+
accordionElement.innerHTML = `
|
|
168
|
+
<button data-controls="panel-1" data-open-sm="false" data-open-lg="true">Trigger</button>
|
|
169
|
+
<div id="panel-1">Panel 1</div>
|
|
170
|
+
`;
|
|
171
|
+
controller.initialize();
|
|
172
|
+
controller._applyBreakpointOpenAttributes();
|
|
173
|
+
expect(accordionElement.querySelector("[data-controls]").getAttribute("data-open")).toBe("true");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("leaves triggers without breakpoint attributes unchanged", () => {
|
|
177
|
+
controller.initialize();
|
|
178
|
+
controller._applyBreakpointOpenAttributes();
|
|
179
|
+
expect(accordionElement.querySelector("#trigger-1").hasAttribute("data-open")).toBe(false);
|
|
180
|
+
expect(accordionElement.querySelector("#trigger-2").hasAttribute("data-open")).toBe(false);
|
|
115
181
|
});
|
|
116
182
|
});
|
|
117
|
-
});
|
|
118
183
|
|
|
184
|
+
describe("_listenForBreakpointChanges", () => {
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
controller.initialize();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("registers change listeners for breakpoints with matching triggers", () => {
|
|
190
|
+
accordionElement.innerHTML = `
|
|
191
|
+
<button data-controls="panel-1" data-open-lg="true">Trigger</button>
|
|
192
|
+
<div id="panel-1">Panel 1</div>
|
|
193
|
+
`;
|
|
194
|
+
controller._listenForBreakpointChanges();
|
|
195
|
+
expect(window.matchMedia).toHaveBeenCalledWith(`(min-width: ${screens.lg})`);
|
|
196
|
+
expect(window.matchMedia.mock.results[0].value.addEventListener).toHaveBeenCalledWith("change", expect.any(Function));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("skips breakpoints without matching triggers", () => {
|
|
200
|
+
accordionElement.innerHTML = `
|
|
201
|
+
<button data-controls="panel-1" data-open-lg="true">Trigger</button>
|
|
202
|
+
<div id="panel-1">Panel 1</div>
|
|
203
|
+
`;
|
|
204
|
+
controller._listenForBreakpointChanges();
|
|
205
|
+
expect(window.matchMedia).toHaveBeenCalledTimes(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("registers nothing when there are no breakpoint triggers", () => {
|
|
209
|
+
controller._listenForBreakpointChanges();
|
|
210
|
+
expect(controller._mediaQueries.length).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("registers listeners for multiple breakpoints", () => {
|
|
214
|
+
accordionElement.innerHTML = `
|
|
215
|
+
<button data-controls="panel-1" data-open-sm="true" data-open-lg="false">Trigger</button>
|
|
216
|
+
<div id="panel-1">Panel 1</div>
|
|
217
|
+
`;
|
|
218
|
+
controller._listenForBreakpointChanges();
|
|
219
|
+
expect(window.matchMedia).toHaveBeenCalledWith(`(min-width: ${screens.sm})`);
|
|
220
|
+
expect(window.matchMedia).toHaveBeenCalledWith(`(min-width: ${screens.lg})`);
|
|
221
|
+
expect(controller._mediaQueries.length).toBe(2);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("_stopListeningForBreakpointChanges", () => {
|
|
226
|
+
beforeEach(() => {
|
|
227
|
+
controller.initialize();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("removes all registered change listeners", () => {
|
|
231
|
+
accordionElement.innerHTML = `
|
|
232
|
+
<button data-controls="panel-1" data-open-lg="true">Trigger</button>
|
|
233
|
+
<div id="panel-1">Panel 1</div>
|
|
234
|
+
`;
|
|
235
|
+
controller._listenForBreakpointChanges();
|
|
236
|
+
const mql = window.matchMedia.mock.results[0].value;
|
|
237
|
+
controller._stopListeningForBreakpointChanges();
|
|
238
|
+
expect(mql.removeEventListener).toHaveBeenCalledWith("change", expect.any(Function));
|
|
239
|
+
expect(controller._mediaQueries.length).toBe(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("does nothing when no listeners exist", () => {
|
|
243
|
+
expect(() => controller._stopListeningForBreakpointChanges()).not.toThrow();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("connect/disconnect/reconnect with breakpoints", () => {
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
controller.initialize();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("connect applies breakpoint attributes and sets up listeners", () => {
|
|
253
|
+
const applySpy = jest.spyOn(controller, "_applyBreakpointOpenAttributes");
|
|
254
|
+
const listenSpy = jest.spyOn(controller, "_listenForBreakpointChanges");
|
|
255
|
+
controller.connect();
|
|
256
|
+
expect(applySpy).toHaveBeenCalled();
|
|
257
|
+
expect(listenSpy).toHaveBeenCalled();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("disconnect tears down breakpoint listeners", () => {
|
|
261
|
+
const spy = jest.spyOn(controller, "_stopListeningForBreakpointChanges");
|
|
262
|
+
controller.disconnect();
|
|
263
|
+
expect(spy).toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("reconnect re-evaluates breakpoint attributes", () => {
|
|
267
|
+
const spy = jest.spyOn(controller, "_applyBreakpointOpenAttributes");
|
|
268
|
+
controller.reconnect({});
|
|
269
|
+
expect(spy).toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -2,6 +2,9 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
import Accordions from "a11y-accordion-component";
|
|
3
3
|
import { screens } from "tailwindcss/defaultTheme"
|
|
4
4
|
export default class extends Controller {
|
|
5
|
+
initialize() {
|
|
6
|
+
this._originalOpenValues = new WeakMap();
|
|
7
|
+
}
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Create accordion from a component
|
|
@@ -16,21 +19,8 @@ export default class extends Controller {
|
|
|
16
19
|
accordionOptions.isMultiSelectable = this.element.dataset.multiselectable !== "false";
|
|
17
20
|
accordionOptions.isCollapsible = this.element.dataset.collapsible !== "false";
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// Ex:
|
|
22
|
-
// data-open="false" data-open-md="true"
|
|
23
|
-
Object.keys(screens).forEach((key) => {
|
|
24
|
-
if (!this.isScreenSize(key)) {
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const elementsToOpen = this.element.querySelectorAll(`[data-controls][data-open-${key}]`);
|
|
29
|
-
|
|
30
|
-
elementsToOpen.forEach((elem) => {
|
|
31
|
-
(elem.dataset.open = elem.dataset[`open-${key}`.replace(/-([a-z])/g, (str) => str[1].toUpperCase())])
|
|
32
|
-
})
|
|
33
|
-
})
|
|
22
|
+
this._applyBreakpointOpenAttributes();
|
|
23
|
+
this._listenForBreakpointChanges();
|
|
34
24
|
|
|
35
25
|
if (!this.element.id) {
|
|
36
26
|
// when component has no id, we enforce to have it one
|
|
@@ -59,20 +49,106 @@ export default class extends Controller {
|
|
|
59
49
|
}
|
|
60
50
|
|
|
61
51
|
if (this.boundExpand) {
|
|
62
|
-
this.toggleButton.
|
|
52
|
+
this.toggleButton.removeEventListener("click", this.boundExpand);
|
|
63
53
|
}
|
|
54
|
+
|
|
55
|
+
this._stopListeningForBreakpointChanges();
|
|
64
56
|
}
|
|
65
57
|
|
|
66
58
|
reconnect(event) {
|
|
67
59
|
this.disconnect();
|
|
68
60
|
|
|
69
|
-
if (event.detail && event.detail.collapse) {
|
|
61
|
+
if (event && event.detail && event.detail.collapse) {
|
|
70
62
|
this.previouslyExpanded = false;
|
|
71
63
|
}
|
|
72
64
|
|
|
73
65
|
this.connect();
|
|
74
66
|
}
|
|
75
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Applies viewport-conditional data-open attributes and resets
|
|
70
|
+
* previously applied values when the viewport no longer matches.
|
|
71
|
+
*
|
|
72
|
+
* @returns {void}
|
|
73
|
+
*/
|
|
74
|
+
_applyBreakpointOpenAttributes() {
|
|
75
|
+
const controlledTriggers = new Set();
|
|
76
|
+
|
|
77
|
+
Object.keys(screens).forEach((key) => {
|
|
78
|
+
this.element.querySelectorAll(`[data-controls][data-open-${key}]`).forEach((elem) => {
|
|
79
|
+
controlledTriggers.add(elem);
|
|
80
|
+
|
|
81
|
+
if (!this._originalOpenValues.has(elem)) {
|
|
82
|
+
this._originalOpenValues.set(elem, elem.getAttribute("data-open"));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Reset all controlled triggers to their original data-open state
|
|
88
|
+
controlledTriggers.forEach((elem) => {
|
|
89
|
+
const original = this._originalOpenValues.get(elem);
|
|
90
|
+
if (original === null) {
|
|
91
|
+
elem.removeAttribute("data-open");
|
|
92
|
+
} else {
|
|
93
|
+
elem.setAttribute("data-open", original);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Apply breakpoint-specific values for the current viewport
|
|
98
|
+
Object.keys(screens).forEach((key) => {
|
|
99
|
+
if (!this.isScreenSize(key)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.element.querySelectorAll(`[data-controls][data-open-${key}]`).forEach((elem) => {
|
|
104
|
+
elem.dataset.open = elem.getAttribute(`data-open-${key}`);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Listens for viewport changes that cross breakpoints relevant
|
|
111
|
+
* to this accordion and reconnects to keep state in sync.
|
|
112
|
+
*
|
|
113
|
+
* @returns {void}
|
|
114
|
+
*/
|
|
115
|
+
_listenForBreakpointChanges() {
|
|
116
|
+
this._mediaQueries = [];
|
|
117
|
+
|
|
118
|
+
Object.keys(screens).forEach((key) => {
|
|
119
|
+
const hasRelevantTriggers = this.element.querySelector(`[data-controls][data-open-${key}]`);
|
|
120
|
+
if (!hasRelevantTriggers) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const mql = window.matchMedia(`(min-width: ${screens[key]})`);
|
|
125
|
+
const handler = () => {
|
|
126
|
+
this.reconnect({ detail: { collapse: true } });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (mql.addEventListener) {
|
|
130
|
+
mql.addEventListener("change", handler);
|
|
131
|
+
} else if (mql.addListener) {
|
|
132
|
+
mql.addListener(handler);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this._mediaQueries.push({ mql, handler });
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_stopListeningForBreakpointChanges() {
|
|
140
|
+
if (this._mediaQueries) {
|
|
141
|
+
this._mediaQueries.forEach(({ mql, handler }) => {
|
|
142
|
+
if (mql.removeEventListener) {
|
|
143
|
+
mql.removeEventListener("change", handler);
|
|
144
|
+
} else if (mql.removeListener) {
|
|
145
|
+
mql.removeListener(handler);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
this._mediaQueries = [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
76
152
|
expandIfNeeded()
|
|
77
153
|
{
|
|
78
154
|
if (!this.toggleButton) {
|
|
@@ -47,6 +47,12 @@ export default class extends Controller {
|
|
|
47
47
|
this.element.id = `dropdown-${Math.random().toString(36).substring(7)}`
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
if (!this.element.hasAttribute("aria-expanded")) {
|
|
51
|
+
this.element.setAttribute("aria-expanded", dropdownOptions.isOpen
|
|
52
|
+
? "true"
|
|
53
|
+
: "false");
|
|
54
|
+
}
|
|
55
|
+
|
|
50
56
|
const autofocus = this.element.dataset.autofocus;
|
|
51
57
|
if (autofocus) {
|
|
52
58
|
// set the focus to some inner element, use setTimeout hack due to waiting for element to display
|
|
@@ -142,6 +142,34 @@ describe("DropdownController", () => {
|
|
|
142
142
|
});
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
describe("aria-expanded initialization", () => {
|
|
146
|
+
it("sets aria-expanded to false when missing and isOpen is false", () => {
|
|
147
|
+
const testController = createController(element);
|
|
148
|
+
|
|
149
|
+
testController.connect();
|
|
150
|
+
|
|
151
|
+
expect(element.getAttribute("aria-expanded")).toBe("false");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("sets aria-expanded to true when missing and data-open is true", () => {
|
|
155
|
+
element.setAttribute("data-open", "true");
|
|
156
|
+
const testController = createController(element);
|
|
157
|
+
|
|
158
|
+
testController.connect();
|
|
159
|
+
|
|
160
|
+
expect(element.getAttribute("aria-expanded")).toBe("true");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("does not override existing aria-expanded attribute", () => {
|
|
164
|
+
element.setAttribute("aria-expanded", "true");
|
|
165
|
+
const testController = createController(element);
|
|
166
|
+
|
|
167
|
+
testController.connect();
|
|
168
|
+
|
|
169
|
+
expect(element.getAttribute("aria-expanded")).toBe("true");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
145
173
|
describe("data-add-aria-roles option", () => {
|
|
146
174
|
it("keeps role menu when data-add-aria-roles is true", () => {
|
|
147
175
|
element.setAttribute("data-add-aria-roles", "true");
|
|
@@ -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,11 @@ 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)
|
|
36
|
+
|
|
37
|
+
if (!this.menuButton.hasAttribute("aria-expanded")) {
|
|
38
|
+
this.menuButton.setAttribute("aria-expanded", "false");
|
|
39
|
+
}
|
|
27
40
|
|
|
28
41
|
this.menuButton.addEventListener("click", this.handleButtonClick)
|
|
29
42
|
this.menuContainer.addEventListener("click", this.handleContainerClick)
|
|
@@ -96,6 +109,23 @@ export default class extends Controller {
|
|
|
96
109
|
return this.menuContainer.getAttribute("aria-hidden") === "true"
|
|
97
110
|
}
|
|
98
111
|
|
|
112
|
+
focusTrapHandler(event) {
|
|
113
|
+
if (event.key !== "Tab") {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const focusable = getFocusableElements(this.menuContainer);
|
|
117
|
+
if (focusable.length === 0) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (event.shiftKey && document.activeElement === focusable[0]) {
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
focusable[focusable.length - 1].focus({ preventScroll: true });
|
|
123
|
+
} else if (!event.shiftKey && document.activeElement === focusable[focusable.length - 1]) {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
focusable[0].focus({ preventScroll: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
99
129
|
openMenu() {
|
|
100
130
|
if (typeof this.previousBodyOverflow === "undefined") {
|
|
101
131
|
this.previousBodyOverflow = document.body.style.overflow;
|
|
@@ -104,9 +134,16 @@ export default class extends Controller {
|
|
|
104
134
|
this.element.setAttribute("aria-expanded", "true")
|
|
105
135
|
this.menuContainer.setAttribute("aria-hidden", "false")
|
|
106
136
|
this.menuContainer.setAttribute("aria-modal", "true")
|
|
137
|
+
this.menuContainer.addEventListener("keydown", this.focusTrapHandler)
|
|
138
|
+
|
|
139
|
+
const focusable = getFocusableElements(this.menuContainer);
|
|
140
|
+
if (focusable.length > 0) {
|
|
141
|
+
focusable[0].focus({ preventScroll: true });
|
|
142
|
+
}
|
|
107
143
|
}
|
|
108
144
|
|
|
109
145
|
closeMenu() {
|
|
146
|
+
this.menuContainer.removeEventListener("keydown", this.focusTrapHandler)
|
|
110
147
|
document.body.style.overflow = this.previousBodyOverflow ?? ""
|
|
111
148
|
this.element.setAttribute("aria-expanded", "false")
|
|
112
149
|
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>
|
|
@@ -68,6 +70,29 @@ describe("MainMenuController", () => {
|
|
|
68
70
|
expect(closeSpy).toHaveBeenCalledWith("click", controller.handleCloseButtonClick)
|
|
69
71
|
})
|
|
70
72
|
|
|
73
|
+
it("sets aria-expanded to false when missing", async () => {
|
|
74
|
+
document.body.innerHTML = `
|
|
75
|
+
<button data-controller="main-menu" data-target="main-menu-container" data-close-button="main-menu-close">
|
|
76
|
+
Menu
|
|
77
|
+
</button>
|
|
78
|
+
<div id="main-menu-container" aria-hidden="true"></div>
|
|
79
|
+
<button id="main-menu-close">Close</button>
|
|
80
|
+
`
|
|
81
|
+
|
|
82
|
+
application.stop()
|
|
83
|
+
application = Application.start()
|
|
84
|
+
application.register("main-menu", MainMenuController)
|
|
85
|
+
|
|
86
|
+
const newButton = document.querySelector('[data-controller="main-menu"]')
|
|
87
|
+
await new Promise((resolve) => {
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
resolve(application.getControllerForElementAndIdentifier(newButton, "main-menu"))
|
|
90
|
+
}, 0)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
expect(newButton.getAttribute("aria-expanded")).toBe("false")
|
|
94
|
+
})
|
|
95
|
+
|
|
71
96
|
it("returns early when menu container is missing", async () => {
|
|
72
97
|
document.body.innerHTML = `
|
|
73
98
|
<button data-controller="main-menu" data-target="missing-menu"></button>
|
|
@@ -165,6 +190,81 @@ describe("MainMenuController", () => {
|
|
|
165
190
|
})
|
|
166
191
|
})
|
|
167
192
|
|
|
193
|
+
describe("focusTrapHandler", () => {
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
jest.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(menuContainer)
|
|
196
|
+
controller.openMenu()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
afterEach(() => {
|
|
200
|
+
controller.closeMenu()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("focuses the first focusable element when menu opens", () => {
|
|
204
|
+
const focusable = menuContainer.querySelectorAll("a[href], button:not([disabled])")
|
|
205
|
+
const firstEl = focusable[0]
|
|
206
|
+
expect(document.activeElement).toBe(firstEl)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("cycles focus to first element when Tab is pressed on last focusable element", () => {
|
|
210
|
+
const focusable = menuContainer.querySelectorAll("a[href], button:not([disabled])")
|
|
211
|
+
expect(focusable.length).toBeGreaterThan(1)
|
|
212
|
+
const lastEl = focusable[focusable.length - 1]
|
|
213
|
+
const firstEl = focusable[0]
|
|
214
|
+
|
|
215
|
+
lastEl.focus()
|
|
216
|
+
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true })
|
|
217
|
+
jest.spyOn(event, "preventDefault")
|
|
218
|
+
jest.spyOn(firstEl, "focus")
|
|
219
|
+
|
|
220
|
+
menuContainer.dispatchEvent(event)
|
|
221
|
+
|
|
222
|
+
expect(event.preventDefault).toHaveBeenCalled()
|
|
223
|
+
expect(firstEl.focus).toHaveBeenCalledWith({ preventScroll: true })
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it("cycles focus to last element when Shift+Tab is pressed on first focusable element", () => {
|
|
227
|
+
const focusable = menuContainer.querySelectorAll("a[href], button:not([disabled])")
|
|
228
|
+
expect(focusable.length).toBeGreaterThan(1)
|
|
229
|
+
const firstEl = focusable[0]
|
|
230
|
+
const lastEl = focusable[focusable.length - 1]
|
|
231
|
+
|
|
232
|
+
firstEl.focus()
|
|
233
|
+
const event = new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true })
|
|
234
|
+
jest.spyOn(event, "preventDefault")
|
|
235
|
+
jest.spyOn(lastEl, "focus")
|
|
236
|
+
|
|
237
|
+
menuContainer.dispatchEvent(event)
|
|
238
|
+
|
|
239
|
+
expect(event.preventDefault).toHaveBeenCalled()
|
|
240
|
+
expect(lastEl.focus).toHaveBeenCalledWith({ preventScroll: true })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("does nothing when a non-Tab key is pressed", () => {
|
|
244
|
+
const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
|
|
245
|
+
jest.spyOn(event, "preventDefault")
|
|
246
|
+
|
|
247
|
+
menuContainer.dispatchEvent(event)
|
|
248
|
+
|
|
249
|
+
expect(event.preventDefault).not.toHaveBeenCalled()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("does nothing when there are no focusable elements", () => {
|
|
253
|
+
controller.closeMenu()
|
|
254
|
+
menuContainer.innerHTML = ""
|
|
255
|
+
controller.openMenu()
|
|
256
|
+
|
|
257
|
+
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true })
|
|
258
|
+
expect(() => menuContainer.dispatchEvent(event)).not.toThrow()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it("removes the keydown handler when menu is closed", () => {
|
|
262
|
+
const spy = jest.spyOn(menuContainer, "removeEventListener")
|
|
263
|
+
controller.closeMenu()
|
|
264
|
+
expect(spy).toHaveBeenCalledWith("keydown", controller.focusTrapHandler)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
168
268
|
describe("disconnect", () => {
|
|
169
269
|
it("removes listeners and closes the menu if open", () => {
|
|
170
270
|
const buttonSpy = jest.spyOn(menuButton, "removeEventListener")
|