@ministryofjustice/frontend 2.2.4 → 3.0.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.
package/moj/all.js CHANGED
@@ -133,6 +133,12 @@ MOJFrontend.initAll = function (options) {
133
133
  MOJFrontend.nodeListForEach($datepickers, function ($datepicker) {
134
134
  new MOJFrontend.DatePicker($datepicker, {}).init();
135
135
  })
136
+
137
+ const $buttonMenus = scope.querySelectorAll('[data-module="moj-button-menu"]')
138
+ MOJFrontend.nodeListForEach($buttonMenus, function ($buttonmenu) {
139
+ new MOJFrontend.ButtonMenu($buttonmenu, {}).init();
140
+ })
141
+
136
142
  }
137
143
 
138
144
  MOJFrontend.AddAnother = function(container) {
@@ -219,162 +225,331 @@ MOJFrontend.AddAnother.prototype.focusHeading = function() {
219
225
  this.container.find('.moj-add-another__heading').focus();
220
226
  };
221
227
 
222
- MOJFrontend.ButtonMenu = function(params) {
223
- this.container = $(params.container);
224
- this.menu = this.container.find('.moj-button-menu__wrapper');
225
- if(params.menuClasses) {
226
- this.menu.addClass(params.menuClasses);
227
- }
228
- this.menu.attr('role', 'menu');
229
- this.mq = params.mq;
230
- this.buttonText = params.buttonText;
231
- this.buttonClasses = params.buttonClasses || '';
232
- this.keys = { esc: 27, up: 38, down: 40, tab: 9 };
233
- this.menu.on('keydown', '[role=menuitem]', $.proxy(this, 'onButtonKeydown'));
234
- this.createToggleButton();
235
- this.setupResponsiveChecks();
236
- $(document).on('click', $.proxy(this, 'onDocumentClick'));
237
- };
228
+ /**
229
+ * @typedef {object} ButtonMenuConfig
230
+ * @property {string} [buttonText=Actions] - Label for the toggle button
231
+ * @property {"left" | "right"} [alignMenu=left] - the alignment of the menu
232
+ * @property {string} [buttonClasses=govuk-button--secondary] - css classes applied to the toggle button
233
+ */
238
234
 
239
- MOJFrontend.ButtonMenu.prototype.onDocumentClick = function(e) {
240
- if(!$.contains(this.container[0], e.target)) {
241
- this.hideMenu();
235
+ /**
236
+ * @param {HTMLElement} $module
237
+ * @param {ButtonMenuConfig} config
238
+ * @constructor
239
+ */
240
+ MOJFrontend.ButtonMenu = function ($module, config = {}) {
241
+ if (!$module) {
242
+ return this;
242
243
  }
243
- };
244
244
 
245
- MOJFrontend.ButtonMenu.prototype.createToggleButton = function() {
246
- this.menuButton = $('<button class="govuk-button moj-button-menu__toggle-button ' + this.buttonClasses + '" type="button" aria-haspopup="true" aria-expanded="false">'+this.buttonText+'</button>');
247
- this.menuButton.on('click', $.proxy(this, 'onMenuButtonClick'));
248
- this.menuButton.on('keydown', $.proxy(this, 'onMenuKeyDown'));
245
+ const schema = Object.freeze({
246
+ properties: {
247
+ buttonText: { type: "string" },
248
+ buttonClasses: { type: "string" },
249
+ alignMenu: { type: "string" },
250
+ },
251
+ });
252
+
253
+ const defaults = {
254
+ buttonText: "Actions",
255
+ alignMenu: "left",
256
+ buttonClasses: "",
257
+ };
258
+ // data attributes override JS config, which overrides defaults
259
+ this.config = this.mergeConfigs(
260
+ defaults,
261
+ config,
262
+ this.parseDataset(schema, $module.dataset),
263
+ );
264
+
265
+ this.$module = $module;
249
266
  };
250
267
 
251
- MOJFrontend.ButtonMenu.prototype.setupResponsiveChecks = function() {
252
- this.mql = window.matchMedia(this.mq);
253
- this.mql.addListener($.proxy(this, 'checkMode'));
254
- this.checkMode(this.mql);
268
+ MOJFrontend.ButtonMenu.prototype.init = function () {
269
+ // If only one button is provided, don't initiate a menu and toggle button
270
+ // if classes have been provided for the toggleButton, apply them to the single item
271
+ if (this.$module.children.length == 1) {
272
+ const button = this.$module.children[0];
273
+ button.classList.forEach((className) => {
274
+ if (className.startsWith("govuk-button-")) {
275
+ button.classList.remove(className);
276
+ }
277
+ button.classList.remove("moj-button-menu__item")
278
+ });
279
+ if (this.config.buttonClasses) {
280
+ button.classList.add(...this.config.buttonClasses.split(" "));
281
+ }
282
+ }
283
+ // Otherwise intialise a button menu
284
+ if (this.$module.children.length > 1) {
285
+ this.initMenu();
286
+ }
255
287
  };
256
288
 
257
- MOJFrontend.ButtonMenu.prototype.checkMode = function(mql) {
258
- if(mql.matches) {
259
- this.enableBigMode();
260
- } else {
261
- this.enableSmallMode();
262
- }
289
+ MOJFrontend.ButtonMenu.prototype.initMenu = function () {
290
+ this.$menu = this.createMenu();
291
+ this.$module.insertAdjacentHTML("afterbegin", this.toggleTemplate());
292
+ this.setupMenuItems();
293
+
294
+ this.$menuToggle = this.$module.querySelector(":scope > button");
295
+ this.items = this.$menu.querySelectorAll("a, button");
296
+
297
+ this.$menuToggle.addEventListener("click", (event) => {
298
+ this.toggleMenu(event);
299
+ });
300
+
301
+ this.$module.addEventListener("keydown", (event) => {
302
+ this.handleKeyDown(event);
303
+ });
304
+
305
+ document.addEventListener("click", (event) => {
306
+ if (!this.$module.contains(event.target)) {
307
+ this.closeMenu(false);
308
+ }
309
+ });
263
310
  };
264
311
 
265
- MOJFrontend.ButtonMenu.prototype.enableSmallMode = function() {
266
- this.container.prepend(this.menuButton);
267
- this.hideMenu();
268
- this.removeButtonClasses();
269
- this.menu.attr('role', 'menu');
270
- this.container.find('.moj-button-menu__item').attr('role', 'menuitem');
312
+ MOJFrontend.ButtonMenu.prototype.createMenu = function () {
313
+ const $menu = document.createElement("ul");
314
+ $menu.setAttribute("role", "list");
315
+ $menu.hidden = true;
316
+ $menu.classList.add("moj-button-menu__wrapper");
317
+ if (this.config.alignMenu == "right") {
318
+ $menu.classList.add("moj-button-menu__wrapper--right");
319
+ }
320
+
321
+ this.$module.appendChild($menu);
322
+ while (this.$module.firstChild !== $menu) {
323
+ $menu.appendChild(this.$module.firstChild);
324
+ }
325
+
326
+ return $menu;
271
327
  };
272
328
 
273
- MOJFrontend.ButtonMenu.prototype.enableBigMode = function() {
274
- this.menuButton.detach();
275
- this.showMenu();
276
- this.addButtonClasses();
277
- this.menu.removeAttr('role');
278
- this.container.find('.moj-button-menu__item').removeAttr('role');
329
+ MOJFrontend.ButtonMenu.prototype.setupMenuItems = function () {
330
+ Array.from(this.$menu.children).forEach((item) => {
331
+ // wrap item in li tag
332
+ const listItem = document.createElement("li");
333
+ this.$menu.insertBefore(listItem, item);
334
+ listItem.appendChild(item);
335
+
336
+ item.setAttribute("tabindex", -1);
337
+
338
+ if (item.tagName == "BUTTON") {
339
+ item.setAttribute("type", "button");
340
+ }
341
+
342
+ item.classList.forEach((className) => {
343
+ if (className.startsWith("govuk-button")) {
344
+ item.classList.remove(className);
345
+ }
346
+ });
347
+
348
+ // add a slight delay after click before closing the menu, makes it *feel* better
349
+ item.addEventListener("click", (event) => {
350
+ setTimeout(() => {
351
+ this.closeMenu(false);
352
+ }, 50);
353
+ });
354
+ });
279
355
  };
280
356
 
281
- MOJFrontend.ButtonMenu.prototype.removeButtonClasses = function() {
282
- this.menu.find('.moj-button-menu__item').each(function(index, el) {
283
- if($(el).hasClass('govuk-button--secondary')) {
284
- $(el).attr('data-secondary', 'true');
285
- $(el).removeClass('govuk-button--secondary');
286
- }
287
- if($(el).hasClass('govuk-button--warning')) {
288
- $(el).attr('data-warning', 'true');
289
- $(el).removeClass('govuk-button--warning');
290
- }
291
- $(el).removeClass('govuk-button');
292
- });
357
+ MOJFrontend.ButtonMenu.prototype.toggleTemplate = function () {
358
+ return `
359
+ <button type="button" class="govuk-button moj-button-menu__toggle-button ${this.config.buttonClasses || ""}" aria-haspopup="true" aria-expanded="false">
360
+ <span>
361
+ ${this.config.buttonText}
362
+ <svg width="11" height="5" viewBox="0 0 11 5" xmlns="http://www.w3.org/2000/svg">
363
+ <path d="M5.5 0L11 5L0 5L5.5 0Z" fill="currentColor"/>
364
+ </svg>
365
+ </span>
366
+ </button>`;
293
367
  };
294
368
 
295
- MOJFrontend.ButtonMenu.prototype.addButtonClasses = function() {
296
- this.menu.find('.moj-button-menu__item').each(function(index, el) {
297
- if($(el).attr('data-secondary') == 'true') {
298
- $(el).addClass('govuk-button--secondary');
299
- }
300
- if($(el).attr('data-warning') == 'true') {
301
- $(el).addClass('govuk-button--warning');
302
- }
303
- $(el).addClass('govuk-button');
304
- });
369
+ /**
370
+ * @returns {boolean}
371
+ */
372
+ MOJFrontend.ButtonMenu.prototype.isOpen = function () {
373
+ return this.$menuToggle.getAttribute("aria-expanded") === "true";
305
374
  };
306
375
 
307
- MOJFrontend.ButtonMenu.prototype.hideMenu = function() {
308
- this.menuButton.attr('aria-expanded', 'false');
376
+ MOJFrontend.ButtonMenu.prototype.toggleMenu = function (event) {
377
+ event.preventDefault();
378
+
379
+ // If menu is triggered with mouse don't move focus to first item
380
+ const keyboardEvent = event.detail == 0;
381
+ const focusIndex = keyboardEvent ? 0 : -1;
382
+
383
+ if (this.isOpen()) {
384
+ this.closeMenu();
385
+ } else {
386
+ this.openMenu(focusIndex);
387
+ }
309
388
  };
310
389
 
311
- MOJFrontend.ButtonMenu.prototype.showMenu = function() {
312
- this.menuButton.attr('aria-expanded', 'true');
390
+ /**
391
+ * Opens the menu and optionally sets the focus to the item with given index
392
+ *
393
+ * @param {number} focusIndex - The index of the item to focus
394
+ */
395
+ MOJFrontend.ButtonMenu.prototype.openMenu = function (focusIndex = 0) {
396
+ this.$menu.hidden = false;
397
+ this.$menuToggle.setAttribute("aria-expanded", "true");
398
+ if (focusIndex !== -1) {
399
+ this.focusItem(focusIndex);
400
+ }
313
401
  };
314
402
 
315
- MOJFrontend.ButtonMenu.prototype.onMenuButtonClick = function() {
316
- this.toggle();
403
+ /**
404
+ * Closes the menu and optionally returns focus back to menuToggle
405
+ *
406
+ * @param {boolean} moveFocus - whether to return focus to the toggle button
407
+ */
408
+ MOJFrontend.ButtonMenu.prototype.closeMenu = function (moveFocus = true) {
409
+ this.$menu.hidden = true;
410
+ this.$menuToggle.setAttribute("aria-expanded", "false");
411
+ if (moveFocus) {
412
+ this.$menuToggle.focus();
413
+ }
317
414
  };
318
415
 
319
- MOJFrontend.ButtonMenu.prototype.toggle = function() {
320
- if(this.menuButton.attr('aria-expanded') == 'false') {
321
- this.showMenu();
322
- this.menu.find('[role=menuitem]').first().focus();
323
- } else {
324
- this.hideMenu();
325
- this.menuButton.focus();
326
- }
416
+ /**
417
+ * Focuses the menu item at the specified index
418
+ *
419
+ * @param {number} index - the index of the item to focus
420
+ */
421
+ MOJFrontend.ButtonMenu.prototype.focusItem = function (index) {
422
+ if (index >= this.items.length) index = 0;
423
+ if (index < 0) index = this.items.length - 1;
424
+
425
+ this.items.item(index)?.focus();
327
426
  };
328
427
 
329
- MOJFrontend.ButtonMenu.prototype.onMenuKeyDown = function(e) {
330
- switch (e.keyCode) {
331
- case this.keys.down:
332
- this.toggle();
333
- break;
334
- }
428
+ MOJFrontend.ButtonMenu.prototype.currentFocusIndex = function () {
429
+ const activeElement = document.activeElement;
430
+ const menuItems = Array.from(this.items);
431
+
432
+ return menuItems.indexOf(activeElement);
335
433
  };
336
434
 
337
- MOJFrontend.ButtonMenu.prototype.onButtonKeydown = function(e) {
338
- switch (e.keyCode) {
339
- case this.keys.up:
340
- e.preventDefault();
341
- this.focusPrevious(e.currentTarget);
342
- break;
343
- case this.keys.down:
344
- e.preventDefault();
345
- this.focusNext(e.currentTarget);
346
- break;
347
- case this.keys.esc:
348
- if(!this.mql.matches) {
349
- this.menuButton.focus();
350
- this.hideMenu();
351
- }
352
- break;
353
- case this.keys.tab:
354
- if(!this.mql.matches) {
355
- this.hideMenu();
356
- }
357
- }
435
+ MOJFrontend.ButtonMenu.prototype.handleKeyDown = function (event) {
436
+ if (event.target == this.$menuToggle) {
437
+ switch (event.key) {
438
+ case "ArrowDown":
439
+ event.preventDefault();
440
+ this.openMenu();
441
+ break;
442
+ case "ArrowUp":
443
+ event.preventDefault();
444
+ this.openMenu(this.items.length - 1);
445
+ break;
446
+ }
447
+ }
448
+
449
+ if (this.$menu.contains(event.target) && this.isOpen()) {
450
+ switch (event.key) {
451
+ case "ArrowDown":
452
+ event.preventDefault();
453
+ if (this.currentFocusIndex() !== -1) {
454
+ this.focusItem(this.currentFocusIndex() + 1);
455
+ }
456
+ break;
457
+ case "ArrowUp":
458
+ event.preventDefault();
459
+ if (this.currentFocusIndex() !== -1) {
460
+ this.focusItem(this.currentFocusIndex() - 1);
461
+ }
462
+ break;
463
+ case "Home":
464
+ event.preventDefault();
465
+ this.focusItem(0);
466
+ break;
467
+ case "End":
468
+ event.preventDefault();
469
+ this.focusItem(this.items.length - 1);
470
+ break;
471
+ }
472
+ }
473
+
474
+ if (event.key == "Escape" && this.isOpen()) {
475
+ this.closeMenu();
476
+ }
477
+ if (event.key == "Tab" && this.isOpen()) {
478
+ this.closeMenu(false);
479
+ }
358
480
  };
359
481
 
360
- MOJFrontend.ButtonMenu.prototype.focusNext = function(currentButton) {
361
- var next = $(currentButton).next();
362
- if(next[0]) {
363
- next.focus();
364
- } else {
365
- this.container.find('[role=menuitem]').first().focus();
366
- }
482
+ /**
483
+ * Parse dataset
484
+ *
485
+ * Loop over an object and normalise each value using {@link normaliseString},
486
+ * optionally expanding nested `i18n.field`
487
+ *
488
+ * @param {{ schema: Schema }} Component - Component class
489
+ * @param {DOMStringMap} dataset - HTML element dataset
490
+ * @returns {Object} Normalised dataset
491
+ */
492
+ MOJFrontend.ButtonMenu.prototype.parseDataset = function (schema, dataset) {
493
+ const parsed = {};
494
+
495
+ for (const [field, attributes] of Object.entries(schema.properties)) {
496
+ if (field in dataset) {
497
+ if (dataset[field]) {
498
+ parsed[field] = dataset[field];
499
+ }
500
+ }
501
+ }
502
+
503
+ return parsed;
367
504
  };
368
505
 
369
- MOJFrontend.ButtonMenu.prototype.focusPrevious = function(currentButton) {
370
- var prev = $(currentButton).prev();
371
- if(prev[0]) {
372
- prev.focus();
373
- } else {
374
- this.container.find('[role=menuitem]').last().focus();
375
- }
506
+ /**
507
+ * Config merging function
508
+ *
509
+ * Takes any number of objects and combines them together, with
510
+ * greatest priority on the LAST item passed in.
511
+ *
512
+ * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
513
+ * @returns {{ [key: string]: unknown }} A merged config object
514
+ */
515
+ MOJFrontend.ButtonMenu.prototype.mergeConfigs = function (...configObjects) {
516
+ const formattedConfigObject = {};
517
+
518
+ // Loop through each of the passed objects
519
+ for (const configObject of configObjects) {
520
+ for (const key of Object.keys(configObject)) {
521
+ const option = formattedConfigObject[key];
522
+ const override = configObject[key];
523
+
524
+ // Push their keys one-by-one into formattedConfigObject. Any duplicate
525
+ // keys with object values will be merged, otherwise the new value will
526
+ // override the existing value.
527
+ if (typeof option === "object" && typeof override === "object") {
528
+ // @ts-expect-error Index signature for type 'string' is missing
529
+ formattedConfigObject[key] = this.mergeConfigs(option, override);
530
+ } else {
531
+ formattedConfigObject[key] = override;
532
+ }
533
+ }
534
+ }
535
+
536
+ return formattedConfigObject;
376
537
  };
377
538
 
539
+ /**
540
+ * Schema for component config
541
+ *
542
+ * @typedef {object} Schema
543
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
544
+ */
545
+
546
+ /**
547
+ * Schema property for component config
548
+ *
549
+ * @typedef {object} SchemaProperty
550
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
551
+ */
552
+
378
553
  /**
379
554
  * Datepicker config
380
555
  *
@@ -471,7 +646,7 @@ Datepicker.prototype.init = function () {
471
646
 
472
647
  this.setOptions();
473
648
  this.initControls();
474
- this.$module.setAttribute('data-initialized', 'true')
649
+ this.$module.setAttribute("data-initialized", "true");
475
650
  };
476
651
 
477
652
  Datepicker.prototype.initControls = function () {
@@ -580,6 +755,7 @@ Datepicker.prototype.createDialog = function () {
580
755
  $dialog.setAttribute("aria-modal", "true");
581
756
  $dialog.setAttribute("aria-labelledby", titleId);
582
757
  $dialog.innerHTML = this.dialogTemplate(titleId);
758
+ $dialog.hidden = true;
583
759
 
584
760
  return $dialog;
585
761
  };
@@ -717,20 +893,14 @@ Datepicker.prototype.setOptions = function () {
717
893
 
718
894
  Datepicker.prototype.setMinAndMaxDatesOnCalendar = function () {
719
895
  if (this.config.minDate) {
720
- this.minDate = this.formattedDateFromString(
721
- this.config.minDate,
722
- null,
723
- );
896
+ this.minDate = this.formattedDateFromString(this.config.minDate, null);
724
897
  if (this.minDate && this.currentDate < this.minDate) {
725
898
  this.currentDate = this.minDate;
726
899
  }
727
900
  }
728
901
 
729
902
  if (this.config.maxDate) {
730
- this.maxDate = this.formattedDateFromString(
731
- this.config.maxDate,
732
- null,
733
- );
903
+ this.maxDate = this.formattedDateFromString(this.config.maxDate, null);
734
904
  if (this.maxDate && this.currentDate > this.maxDate) {
735
905
  this.currentDate = this.maxDate;
736
906
  }
@@ -860,7 +1030,7 @@ Datepicker.prototype.formattedDateFromString = function (
860
1030
  const month = match[3];
861
1031
  const year = match[4];
862
1032
 
863
- formattedDate = new Date(`${month}-${day}-${year}`);
1033
+ formattedDate = new Date(`${year}-${month}-${day}`);
864
1034
  if (formattedDate instanceof Date && !isNaN(formattedDate)) {
865
1035
  return formattedDate;
866
1036
  }
@@ -948,7 +1118,6 @@ Datepicker.prototype.updateCalendar = function () {
948
1118
 
949
1119
  Datepicker.prototype.setCurrentDate = function (focus = true) {
950
1120
  const { currentDate } = this;
951
-
952
1121
  this.calendarDays.forEach((calendarDay) => {
953
1122
  calendarDay.button.classList.add("moj-datepicker__button");
954
1123
  calendarDay.button.classList.add("moj-datepicker__calendar-day");
@@ -976,10 +1145,10 @@ Datepicker.prototype.setCurrentDate = function (focus = true) {
976
1145
  calendarDayDate.getTime() === this.inputDate.getTime()
977
1146
  ) {
978
1147
  calendarDay.button.classList.add(this.currentDayButtonClass);
979
- calendarDay.button.setAttribute("aria-selected", true);
1148
+ calendarDay.button.setAttribute("aria-current", "date");
980
1149
  } else {
981
1150
  calendarDay.button.classList.remove(this.currentDayButtonClass);
982
- calendarDay.button.removeAttribute("aria-selected");
1151
+ calendarDay.button.removeAttribute("aria-current");
983
1152
  }
984
1153
 
985
1154
  if (calendarDayDate.getTime() === today.getTime()) {
@@ -1034,6 +1203,7 @@ Datepicker.prototype.toggleDialog = function (event) {
1034
1203
  };
1035
1204
 
1036
1205
  Datepicker.prototype.openDialog = function () {
1206
+ this.$dialog.hidden = false;
1037
1207
  this.$dialog.classList.add("moj-datepicker__dialog--open");
1038
1208
  this.$calendarButton.setAttribute("aria-expanded", "true");
1039
1209
 
@@ -1054,6 +1224,7 @@ Datepicker.prototype.openDialog = function () {
1054
1224
  };
1055
1225
 
1056
1226
  Datepicker.prototype.closeDialog = function () {
1227
+ this.$dialog.hidden = true;
1057
1228
  this.$dialog.classList.remove("moj-datepicker__dialog--open");
1058
1229
  this.$calendarButton.setAttribute("aria-expanded", "false");
1059
1230
  this.$calendarButton.focus();
@@ -1101,13 +1272,31 @@ Datepicker.prototype.focusPreviousWeek = function () {
1101
1272
 
1102
1273
  Datepicker.prototype.focusFirstDayOfWeek = function () {
1103
1274
  const date = new Date(this.currentDate);
1104
- date.setDate(date.getDate() - date.getDay());
1275
+ const firstDayOfWeekIndex = this.config.weekStartDay == "sunday" ? 0 : 1;
1276
+ const dayOfWeek = date.getDay();
1277
+ const diff =
1278
+ dayOfWeek >= firstDayOfWeekIndex
1279
+ ? dayOfWeek - firstDayOfWeekIndex
1280
+ : 6 - dayOfWeek;
1281
+
1282
+ date.setDate(date.getDate() - diff);
1283
+ date.setHours(0, 0, 0, 0);
1284
+
1105
1285
  this.goToDate(date);
1106
1286
  };
1107
1287
 
1108
1288
  Datepicker.prototype.focusLastDayOfWeek = function () {
1109
1289
  const date = new Date(this.currentDate);
1110
- date.setDate(date.getDate() - date.getDay() + 6);
1290
+ const lastDayOfWeekIndex = this.config.weekStartDay == "sunday" ? 6 : 0;
1291
+ const dayOfWeek = date.getDay();
1292
+ const diff =
1293
+ dayOfWeek <= lastDayOfWeekIndex
1294
+ ? lastDayOfWeekIndex - dayOfWeek
1295
+ : 7 - dayOfWeek;
1296
+
1297
+ date.setDate(date.getDate() + diff);
1298
+ date.setHours(0, 0, 0, 0);
1299
+
1111
1300
  this.goToDate(date);
1112
1301
  };
1113
1302