@ministryofjustice/frontend 2.2.5 → 3.0.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.
@@ -0,0 +1,363 @@
1
+ const { queryByRole, screen } = require("@testing-library/dom");
2
+ const { userEvent } = require("@testing-library/user-event");
3
+ const { configureAxe, toHaveNoViolations } = require("jest-axe");
4
+ expect.extend(toHaveNoViolations);
5
+
6
+ require("../../../../jest.setup.js");
7
+ require("./button-menu.js");
8
+
9
+ const user = userEvent.setup();
10
+ const axe = configureAxe({
11
+ rules: {
12
+ // disable landmark rules when testing isolated components.
13
+ region: { enabled: false },
14
+ },
15
+ });
16
+
17
+ const kebabize = (str) => {
18
+ return str.replace(
19
+ /[A-Z]+(?![a-z])|[A-Z]/g,
20
+ ($, ofset) => (ofset ? "-" : "") + $.toLowerCase(),
21
+ );
22
+ };
23
+
24
+ const configToDataAttributes = (config) => {
25
+ let attributes = "";
26
+ for (let [key, value] of Object.entries(config)) {
27
+ attributes += `data-${kebabize(key)}="${value}" `;
28
+ }
29
+ return attributes;
30
+ };
31
+
32
+ const createComponent = (config = {}, html) => {
33
+ const dataAttributes = configToDataAttributes(config);
34
+ if(typeof html === "undefined") {
35
+ html = `
36
+ <div class="moj-button-menu" data-module="moj-button-menu" ${dataAttributes}>
37
+ <a href="#one" role="button">First action</a>
38
+ <a href="#two" role="button" class="govuk-button--warning">Second action</a>
39
+ <a href="#three" role="button" class="custom-class">Third action</a>
40
+ </div>`;
41
+ }
42
+ document.body.insertAdjacentHTML("afterbegin", html);
43
+
44
+ component = document.querySelector('[data-module="moj-button-menu"]');
45
+ return component;
46
+ };
47
+
48
+ describe("Button menu with defaults", () => {
49
+ let component;
50
+ let toggleButton;
51
+ let menu;
52
+ let items;
53
+
54
+ beforeEach(() => {
55
+ component = createComponent();
56
+ new MOJFrontend.ButtonMenu(component).init();
57
+
58
+ toggleButton = queryByRole(component, "button", { hidden: false });
59
+ menu = screen.queryByRole("list", { hidden: true });
60
+ items = menu?.querySelectorAll("a, button");
61
+ });
62
+
63
+ afterEach(() => {
64
+ document.body.innerHTML = "";
65
+ });
66
+
67
+ test("initialises component elements", () => {
68
+ expect(toggleButton).not.toBeNull();
69
+ expect(menu).not.toBeNull();
70
+ expect(items).not.toBeNull();
71
+ });
72
+
73
+ test("intialises toggle button", () => {
74
+ expect(component).toContainElement(toggleButton);
75
+ expect(toggleButton).toHaveAttribute("aria-expanded", "false");
76
+ expect(toggleButton).toHaveAttribute("aria-haspopup", "true");
77
+ });
78
+
79
+ test("intialises menu", () => {
80
+ expect(component).toContainElement(menu);
81
+ expect(menu).not.toBeVisible();
82
+ });
83
+
84
+ test("creates menuitems", () => {
85
+ expect(items.length).toBe(3);
86
+ });
87
+
88
+ test("removes other govuk-button classes from menuitems", () => {
89
+ expect(items[1]).not.toHaveClass("govuk-button--warning");
90
+ });
91
+
92
+ test("keeps custom classes on items", () => {
93
+ expect(items[2]).toHaveClass("custom-class");
94
+ });
95
+
96
+ test("clicking toggle button shows menu", async () => {
97
+ await user.click(toggleButton);
98
+
99
+ expect(menu).toBeVisible();
100
+ expect(toggleButton).toHaveAttribute("aria-expanded", "true");
101
+ });
102
+
103
+ test("clicking a link in the menu", async () => {
104
+ await user.click(toggleButton);
105
+
106
+ expect(menu).toBeVisible();
107
+ await user.click(items[0]);
108
+ expect(global.window.location.hash).toContain("#one");
109
+ await user.click(items[2]);
110
+ expect(global.window.location.hash).toContain("#three");
111
+ });
112
+
113
+ test("clicking outside closes menu", async () => {
114
+ await user.click(toggleButton);
115
+ expect(menu).toBeVisible();
116
+
117
+ await user.click(document.body);
118
+ expect(menu).not.toBeVisible();
119
+ });
120
+
121
+ describe("keyboard interactions", () => {
122
+ test("enter on toggle button opens menu", async () => {
123
+ toggleButton.focus();
124
+ await user.keyboard("[Enter]");
125
+
126
+ expect(menu).toBeVisible();
127
+ expect(toggleButton).toHaveAttribute("aria-expanded", "true");
128
+ expect(items[0]).toHaveFocus();
129
+ });
130
+
131
+ test("space on toggle button opens menu", async () => {
132
+ toggleButton.focus();
133
+ await user.keyboard("[Space]");
134
+
135
+ expect(menu).toBeVisible();
136
+ expect(toggleButton).toHaveAttribute("aria-expanded", "true");
137
+ expect(items[0]).toHaveFocus();
138
+ });
139
+
140
+ test("esc closes menu", async () => {
141
+ toggleButton.focus();
142
+ await user.keyboard("[Space]");
143
+ expect(menu).toBeVisible();
144
+ await user.keyboard("[Escape]");
145
+
146
+ expect(menu).not.toBeVisible();
147
+ expect(toggleButton).toHaveFocus();
148
+ });
149
+
150
+ test("down arrow on toggle button opens menu with focus on first item", async () => {
151
+ toggleButton.focus();
152
+ await user.keyboard("[ArrowDown]");
153
+
154
+ expect(menu).toBeVisible();
155
+ expect(toggleButton).toHaveAttribute("aria-expanded", "true");
156
+ expect(items[0]).toHaveFocus();
157
+ });
158
+
159
+ test("up arrow on toggle button opens menu with focus on last item", async () => {
160
+ toggleButton.focus();
161
+ await user.keyboard("[ArrowUp]");
162
+
163
+ expect(menu).toBeVisible();
164
+ expect(toggleButton).toHaveAttribute("aria-expanded", "true");
165
+ expect(items[items.length - 1]).toHaveFocus();
166
+ });
167
+
168
+ test("down arrow on menu item navigates to next item with looping", async () => {
169
+ toggleButton.focus();
170
+ await user.keyboard("[Enter]");
171
+ expect(items[0]).toHaveFocus();
172
+
173
+ await user.keyboard("[ArrowDown]");
174
+ expect(items[1]).toHaveFocus();
175
+
176
+ await user.keyboard("[ArrowDown]");
177
+ expect(items[2]).toHaveFocus();
178
+
179
+ await user.keyboard("[ArrowDown]");
180
+ expect(items[0]).toHaveFocus();
181
+ });
182
+
183
+ test("up arrow on menu item navigates to previous item with looping", async () => {
184
+ toggleButton.focus();
185
+ await user.keyboard("[ArrowUp]");
186
+ expect(items[items.length - 1]).toHaveFocus();
187
+
188
+ await user.keyboard("[ArrowUp]");
189
+ expect(items[1]).toHaveFocus();
190
+
191
+ await user.keyboard("[ArrowUp]");
192
+ expect(items[0]).toHaveFocus();
193
+
194
+ await user.keyboard("[ArrowUp]");
195
+ expect(items[items.length - 1]).toHaveFocus();
196
+ });
197
+
198
+ test("home navigates to first item", async () => {
199
+ toggleButton.focus();
200
+ await user.keyboard("[ArrowUp]");
201
+ expect(items[items.length - 1]).toHaveFocus();
202
+
203
+ await user.keyboard("[Home]");
204
+ expect(items[0]).toHaveFocus();
205
+ });
206
+
207
+ test("end navigates to last item", async () => {
208
+ toggleButton.focus();
209
+ await user.keyboard("[Enter]");
210
+ expect(items[0]).toHaveFocus();
211
+
212
+ await user.keyboard("[End]");
213
+ expect(items[items.length - 1]).toHaveFocus();
214
+ });
215
+
216
+ test("tab moves focus out of the menu", async () => {
217
+ toggleButton.focus();
218
+ await user.keyboard("[Enter]");
219
+ expect(menu).toBeVisible();
220
+ expect(items[0]).toHaveFocus();
221
+ await user.tab();
222
+
223
+ expect(document.body).toHaveFocus();
224
+ expect(menu).not.toBeVisible();
225
+ });
226
+ });
227
+
228
+ describe("accessibility", () => {
229
+ test("component has no wcag violations", async () => {
230
+ expect(await axe(document.body)).toHaveNoViolations();
231
+ await user.click(toggleButton);
232
+ expect(await axe(document.body)).toHaveNoViolations();
233
+ });
234
+ });
235
+ });
236
+
237
+ describe("Button menu javascript API", () => {
238
+ let component;
239
+
240
+ beforeEach(() => {
241
+ component = createComponent();
242
+ });
243
+
244
+ afterEach(() => {
245
+ document.body.innerHTML = "";
246
+ });
247
+
248
+ test("setting toggle button text", () => {
249
+ const label = "click me";
250
+ new MOJFrontend.ButtonMenu(component, { buttonText: label }).init();
251
+ const toggleButton = queryByRole(component, "button", { name: label });
252
+
253
+ expect(toggleButton).not.toBeNull;
254
+ });
255
+
256
+ test("setting menu alignment", () => {
257
+ new MOJFrontend.ButtonMenu(component, { alignMenu: "right" }).init();
258
+ const menu = screen.queryByRole("list", { hidden: true });
259
+
260
+ expect(menu).toHaveClass("moj-button-menu__wrapper--right");
261
+ });
262
+
263
+ test("setting button classes", () => {
264
+ const defaultClassNames = "govuk-button moj-button-menu__toggle-button";
265
+ const classNames = "classOne classTwo";
266
+
267
+ new MOJFrontend.ButtonMenu(component, { buttonClasses: classNames }).init();
268
+ const toggleButton = queryByRole(component, "button", { hidden: false });
269
+
270
+ expect(toggleButton).toHaveClass(defaultClassNames);
271
+ expect(toggleButton).toHaveClass(classNames);
272
+ });
273
+ });
274
+
275
+ describe("Button menu data-attributes API", () => {
276
+ let component;
277
+
278
+ beforeEach(() => {});
279
+
280
+ afterEach(() => {
281
+ document.body.innerHTML = "";
282
+ });
283
+
284
+ test("setting toggle button text", () => {
285
+ const label = "click me";
286
+
287
+ component = createComponent({ buttonText: label });
288
+ new MOJFrontend.ButtonMenu(component).init();
289
+ const toggleButton = queryByRole(component, "button", { name: label });
290
+
291
+ expect(toggleButton).not.toBeNull();
292
+ });
293
+
294
+ test("setting menu alignment", () => {
295
+ component = createComponent({ alignMenu: "right" });
296
+ new MOJFrontend.ButtonMenu(component).init();
297
+ const menu = screen.queryByRole("list", { hidden: true });
298
+
299
+ expect(menu).toHaveClass("moj-button-menu__wrapper--right");
300
+ });
301
+
302
+ test("setting button classes", () => {
303
+ const defaultClassNames = "govuk-button moj-button-menu__toggle-button";
304
+ const classNames = "classOne classTwo";
305
+
306
+ component = createComponent({ buttonClasses: classNames });
307
+ new MOJFrontend.ButtonMenu(component).init();
308
+ const toggleButton = queryByRole(component, "button", { hidden: false });
309
+
310
+ expect(toggleButton).toHaveClass(defaultClassNames);
311
+ expect(toggleButton).toHaveClass(classNames);
312
+ });
313
+ });
314
+
315
+ describe("menu button with a single item", () => {
316
+ let component;
317
+ let toggleButton;
318
+ let menu;
319
+ let items;
320
+
321
+ beforeEach(() => {
322
+ const html = `
323
+ <div class="moj-button-menu" data-module="moj-button-menu" data-button-classes="govuk-button--warning custom-class">
324
+ <a href="#one" role="button" class="govuk-button--inverse">First action</a>
325
+ </div>`;
326
+
327
+ component = createComponent({}, html);
328
+ new MOJFrontend.ButtonMenu(component).init();
329
+
330
+ toggleButton = queryByRole(component, "button", { name: "Actions" });
331
+ menu = screen.queryByRole("list", { hidden: true });
332
+ items = menu?.queryByRole("button", { hidden: true });
333
+ });
334
+
335
+ afterEach(() => {
336
+ document.body.innerHTML = "";
337
+ });
338
+
339
+ test("menu is not created", () => {
340
+ expect(menu).toBeNull();
341
+ });
342
+
343
+ test("there are no items", () => {
344
+ expect(items).toBeUndefined();
345
+ });
346
+
347
+ test("there is no toggle button", () => {
348
+ expect(toggleButton).toBeNull();
349
+ });
350
+
351
+ test("first item has become button", () => {
352
+ const button = screen.queryByRole("button", { name: "First action" });
353
+
354
+ expect(button).not.toBeNull();
355
+ });
356
+
357
+ test("first item has buttonClasses config applied", () => {
358
+ const button = screen.queryByRole("button", { name: "First action" });
359
+
360
+ expect(button).toHaveClass("govuk-button--warning", "custom-class");
361
+ expect(button).not.toHaveClass("govuk-button--inverse");
362
+ });
363
+ });
@@ -1,11 +1,38 @@
1
1
  {%- from "govuk/components/button/macro.njk" import govukButton %}
2
+ {% from "govuk/macros/attributes.njk" import govukAttributes %}
2
3
 
3
- <div class="moj-button-menu {{- ' ' + params.classes if params.classes }}" {%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor %}>
4
- <div class="moj-button-menu__wrapper">
4
+ {#- Set classes for this component #}
5
+ {%- set classNames = "moj-button-menu" -%}
6
+ {%- set itemClassNames = "moj-button-menu__item govuk-button--secondary" %}
7
+
8
+ {%- if params.classes %}
9
+ {% set classNames = classNames + " " + params.classes %}
10
+ {% endif %}
11
+
12
+ {%- set buttonAttributes = {
13
+ "data-button-text": {
14
+ value: params.button.text,
15
+ optional: true
16
+ },
17
+ "data-button-classes": {
18
+ value: params.button.classes,
19
+ optional: true
20
+ },
21
+ "data-align-menu": {
22
+ value: params.alignMenu,
23
+ optional: true
24
+ }
25
+ }
26
+ %}
27
+
28
+ <div class="{{- classNames -}}" data-module="moj-button-menu" {{- govukAttributes(params.attributes) -}} {{- govukAttributes(buttonAttributes) -}}>
5
29
  {%- for item in params.items %}
6
- {{ govukButton({
30
+ {%- if item.classes %}
31
+ {% set itemClassNames = itemClassNames + " " + item.classes %}
32
+ {% endif %}
33
+ {{ govukButton({
7
34
  element: item.element,
8
- classes: 'moj-button-menu__item ' + (item.classes if item.classes) + ' ' + (params.buttonClasses if params.buttonClasses),
35
+ classes: itemClassNames,
9
36
  text: item.text,
10
37
  html: item.html,
11
38
  name: item.name,
@@ -15,8 +42,7 @@
15
42
  disabled: item.disabled,
16
43
  attributes: item.attributes,
17
44
  preventDoubleClick: items.preventDoubleClick
18
- }) }}
45
+ }) }}
19
46
  {% endfor -%}
20
- </div>
21
47
  </div>
22
48
 
@@ -94,7 +94,7 @@ Datepicker.prototype.init = function () {
94
94
 
95
95
  this.setOptions();
96
96
  this.initControls();
97
- this.$module.setAttribute('data-initialized', 'true')
97
+ this.$module.setAttribute("data-initialized", "true");
98
98
  };
99
99
 
100
100
  Datepicker.prototype.initControls = function () {
@@ -203,6 +203,7 @@ Datepicker.prototype.createDialog = function () {
203
203
  $dialog.setAttribute("aria-modal", "true");
204
204
  $dialog.setAttribute("aria-labelledby", titleId);
205
205
  $dialog.innerHTML = this.dialogTemplate(titleId);
206
+ $dialog.hidden = true;
206
207
 
207
208
  return $dialog;
208
209
  };
@@ -340,20 +341,14 @@ Datepicker.prototype.setOptions = function () {
340
341
 
341
342
  Datepicker.prototype.setMinAndMaxDatesOnCalendar = function () {
342
343
  if (this.config.minDate) {
343
- this.minDate = this.formattedDateFromString(
344
- this.config.minDate,
345
- null,
346
- );
344
+ this.minDate = this.formattedDateFromString(this.config.minDate, null);
347
345
  if (this.minDate && this.currentDate < this.minDate) {
348
346
  this.currentDate = this.minDate;
349
347
  }
350
348
  }
351
349
 
352
350
  if (this.config.maxDate) {
353
- this.maxDate = this.formattedDateFromString(
354
- this.config.maxDate,
355
- null,
356
- );
351
+ this.maxDate = this.formattedDateFromString(this.config.maxDate, null);
357
352
  if (this.maxDate && this.currentDate > this.maxDate) {
358
353
  this.currentDate = this.maxDate;
359
354
  }
@@ -483,7 +478,7 @@ Datepicker.prototype.formattedDateFromString = function (
483
478
  const month = match[3];
484
479
  const year = match[4];
485
480
 
486
- formattedDate = new Date(`${month}-${day}-${year}`);
481
+ formattedDate = new Date(`${year}-${month}-${day}`);
487
482
  if (formattedDate instanceof Date && !isNaN(formattedDate)) {
488
483
  return formattedDate;
489
484
  }
@@ -571,7 +566,6 @@ Datepicker.prototype.updateCalendar = function () {
571
566
 
572
567
  Datepicker.prototype.setCurrentDate = function (focus = true) {
573
568
  const { currentDate } = this;
574
-
575
569
  this.calendarDays.forEach((calendarDay) => {
576
570
  calendarDay.button.classList.add("moj-datepicker__button");
577
571
  calendarDay.button.classList.add("moj-datepicker__calendar-day");
@@ -599,10 +593,10 @@ Datepicker.prototype.setCurrentDate = function (focus = true) {
599
593
  calendarDayDate.getTime() === this.inputDate.getTime()
600
594
  ) {
601
595
  calendarDay.button.classList.add(this.currentDayButtonClass);
602
- calendarDay.button.setAttribute("aria-selected", true);
596
+ calendarDay.button.setAttribute("aria-current", "date");
603
597
  } else {
604
598
  calendarDay.button.classList.remove(this.currentDayButtonClass);
605
- calendarDay.button.removeAttribute("aria-selected");
599
+ calendarDay.button.removeAttribute("aria-current");
606
600
  }
607
601
 
608
602
  if (calendarDayDate.getTime() === today.getTime()) {
@@ -657,6 +651,7 @@ Datepicker.prototype.toggleDialog = function (event) {
657
651
  };
658
652
 
659
653
  Datepicker.prototype.openDialog = function () {
654
+ this.$dialog.hidden = false;
660
655
  this.$dialog.classList.add("moj-datepicker__dialog--open");
661
656
  this.$calendarButton.setAttribute("aria-expanded", "true");
662
657
 
@@ -677,6 +672,7 @@ Datepicker.prototype.openDialog = function () {
677
672
  };
678
673
 
679
674
  Datepicker.prototype.closeDialog = function () {
675
+ this.$dialog.hidden = true;
680
676
  this.$dialog.classList.remove("moj-datepicker__dialog--open");
681
677
  this.$calendarButton.setAttribute("aria-expanded", "false");
682
678
  this.$calendarButton.focus();
@@ -724,13 +720,31 @@ Datepicker.prototype.focusPreviousWeek = function () {
724
720
 
725
721
  Datepicker.prototype.focusFirstDayOfWeek = function () {
726
722
  const date = new Date(this.currentDate);
727
- date.setDate(date.getDate() - date.getDay());
723
+ const firstDayOfWeekIndex = this.config.weekStartDay == "sunday" ? 0 : 1;
724
+ const dayOfWeek = date.getDay();
725
+ const diff =
726
+ dayOfWeek >= firstDayOfWeekIndex
727
+ ? dayOfWeek - firstDayOfWeekIndex
728
+ : 6 - dayOfWeek;
729
+
730
+ date.setDate(date.getDate() - diff);
731
+ date.setHours(0, 0, 0, 0);
732
+
728
733
  this.goToDate(date);
729
734
  };
730
735
 
731
736
  Datepicker.prototype.focusLastDayOfWeek = function () {
732
737
  const date = new Date(this.currentDate);
733
- date.setDate(date.getDate() - date.getDay() + 6);
738
+ const lastDayOfWeekIndex = this.config.weekStartDay == "sunday" ? 6 : 0;
739
+ const dayOfWeek = date.getDay();
740
+ const diff =
741
+ dayOfWeek <= lastDayOfWeekIndex
742
+ ? lastDayOfWeekIndex - dayOfWeek
743
+ : 7 - dayOfWeek;
744
+
745
+ date.setDate(date.getDate() + diff);
746
+ date.setHours(0, 0, 0, 0);
747
+
734
748
  this.goToDate(date);
735
749
  };
736
750