@ministryofjustice/frontend 3.5.0 → 3.6.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 (36) hide show
  1. package/moj/all.jquery.js +13378 -0
  2. package/moj/all.jquery.min.js +1 -81
  3. package/moj/all.js +2577 -2853
  4. package/moj/all.mjs +126 -0
  5. package/moj/components/add-another/add-another.js +111 -132
  6. package/moj/components/add-another/add-another.mjs +106 -0
  7. package/moj/components/alert/alert.js +352 -479
  8. package/moj/components/alert/alert.mjs +251 -0
  9. package/moj/components/alert/alert.spec.helper.js +6 -24
  10. package/moj/components/alert/alert.spec.helper.mjs +66 -0
  11. package/moj/components/button-menu/button-menu.js +326 -343
  12. package/moj/components/button-menu/button-menu.mjs +329 -0
  13. package/moj/components/date-picker/date-picker.js +905 -922
  14. package/moj/components/date-picker/date-picker.mjs +961 -0
  15. package/moj/components/filter-toggle-button/filter-toggle-button.js +98 -119
  16. package/moj/components/filter-toggle-button/filter-toggle-button.mjs +93 -0
  17. package/moj/components/form-validator/form-validator.js +201 -396
  18. package/moj/components/form-validator/form-validator.mjs +168 -0
  19. package/moj/components/multi-file-upload/multi-file-upload.js +227 -441
  20. package/moj/components/multi-file-upload/multi-file-upload.mjs +219 -0
  21. package/moj/components/multi-select/multi-select.js +82 -103
  22. package/moj/components/multi-select/multi-select.mjs +77 -0
  23. package/moj/components/password-reveal/password-reveal.js +40 -61
  24. package/moj/components/password-reveal/password-reveal.mjs +35 -0
  25. package/moj/components/rich-text-editor/rich-text-editor.js +162 -183
  26. package/moj/components/rich-text-editor/rich-text-editor.mjs +157 -0
  27. package/moj/components/search-toggle/search-toggle.js +52 -73
  28. package/moj/components/search-toggle/search-toggle.mjs +54 -0
  29. package/moj/components/sortable-table/sortable-table.js +143 -164
  30. package/moj/components/sortable-table/sortable-table.mjs +138 -0
  31. package/moj/helpers.js +196 -215
  32. package/moj/helpers.mjs +123 -0
  33. package/moj/moj-frontend.min.js +1 -81
  34. package/moj/version.js +6 -23
  35. package/moj/version.mjs +3 -0
  36. package/package.json +13 -1
@@ -0,0 +1,961 @@
1
+ /**
2
+ * Date picker config
3
+ *
4
+ * @typedef {object} DatePickerConfig
5
+ * @property {string} [excludedDates] - Dates that cannot be selected
6
+ * @property {string} [excludedDays] - Days that cannot be selected
7
+ * @property {boolean} [leadingZeroes] - Whether to add leading zeroes when populating the field
8
+ * @property {string} [minDate] - The earliest available date
9
+ * @property {string} [maxDate] - The latest available date
10
+ * @property {string} [weekStartDay] - First day of the week in calendar view
11
+ */
12
+
13
+ /**
14
+ * @param {HTMLElement} $module - HTML element
15
+ * @param {DatePickerConfig} config - config object
16
+ * @class
17
+ */
18
+ function DatePicker($module, config = {}) {
19
+ if (!$module) {
20
+ return this
21
+ }
22
+
23
+ const schema = Object.freeze({
24
+ properties: {
25
+ excludedDates: { type: 'string' },
26
+ excludedDays: { type: 'string' },
27
+ leadingZeros: { type: 'string' },
28
+ maxDate: { type: 'string' },
29
+ minDate: { type: 'string' },
30
+ weekStartDay: { type: 'string' }
31
+ }
32
+ });
33
+
34
+ const defaults = {
35
+ leadingZeros: false,
36
+ weekStartDay: 'monday'
37
+ };
38
+
39
+ // data attributes override JS config, which overrides defaults
40
+ this.config = this.mergeConfigs(
41
+ defaults,
42
+ config,
43
+ this.parseDataset(schema, $module.dataset)
44
+ );
45
+
46
+ this.dayLabels = [
47
+ 'Monday',
48
+ 'Tuesday',
49
+ 'Wednesday',
50
+ 'Thursday',
51
+ 'Friday',
52
+ 'Saturday',
53
+ 'Sunday'
54
+ ];
55
+
56
+ this.monthLabels = [
57
+ 'January',
58
+ 'February',
59
+ 'March',
60
+ 'April',
61
+ 'May',
62
+ 'June',
63
+ 'July',
64
+ 'August',
65
+ 'September',
66
+ 'October',
67
+ 'November',
68
+ 'December'
69
+ ];
70
+
71
+ this.currentDate = new Date();
72
+ this.currentDate.setHours(0, 0, 0, 0);
73
+ this.calendarDays = [];
74
+ this.excludedDates = [];
75
+ this.excludedDays = [];
76
+
77
+ this.buttonClass = 'moj-datepicker__button';
78
+ this.selectedDayButtonClass = 'moj-datepicker__button--selected';
79
+ this.currentDayButtonClass = 'moj-datepicker__button--current';
80
+ this.todayButtonClass = 'moj-datepicker__button--today';
81
+
82
+ this.$module = $module;
83
+ this.$input = $module.querySelector('.moj-js-datepicker-input');
84
+ }
85
+
86
+ DatePicker.prototype.init = function () {
87
+ // Check that required elements are present
88
+ if (!this.$input) {
89
+ return
90
+ }
91
+ if (this.$module.dataset.initialized) {
92
+ return
93
+ }
94
+
95
+ this.setOptions();
96
+ this.initControls();
97
+ this.$module.setAttribute('data-initialized', 'true');
98
+ };
99
+
100
+ DatePicker.prototype.initControls = function () {
101
+ this.id = `datepicker-${this.$input.id}`;
102
+
103
+ this.$dialog = this.createDialog();
104
+ this.createCalendarHeaders();
105
+
106
+ const $componentWrapper = document.createElement('div');
107
+ const $inputWrapper = document.createElement('div');
108
+ $componentWrapper.classList.add('moj-datepicker__wrapper');
109
+ $inputWrapper.classList.add('govuk-input__wrapper');
110
+
111
+ this.$input.parentNode.insertBefore($componentWrapper, this.$input);
112
+ $componentWrapper.appendChild($inputWrapper);
113
+ $inputWrapper.appendChild(this.$input);
114
+
115
+ $inputWrapper.insertAdjacentHTML('beforeend', this.toggleTemplate());
116
+ $componentWrapper.insertAdjacentElement('beforeend', this.$dialog);
117
+
118
+ this.$calendarButton = this.$module.querySelector('.moj-js-datepicker-toggle');
119
+ this.$dialogTitle = this.$dialog.querySelector(
120
+ '.moj-js-datepicker-month-year'
121
+ );
122
+
123
+ this.createCalendar();
124
+
125
+ this.$prevMonthButton = this.$dialog.querySelector(
126
+ '.moj-js-datepicker-prev-month'
127
+ );
128
+ this.$prevYearButton = this.$dialog.querySelector(
129
+ '.moj-js-datepicker-prev-year'
130
+ );
131
+ this.$nextMonthButton = this.$dialog.querySelector(
132
+ '.moj-js-datepicker-next-month'
133
+ );
134
+ this.$nextYearButton = this.$dialog.querySelector(
135
+ '.moj-js-datepicker-next-year'
136
+ );
137
+ this.$cancelButton = this.$dialog.querySelector('.moj-js-datepicker-cancel');
138
+ this.$okButton = this.$dialog.querySelector('.moj-js-datepicker-ok');
139
+
140
+ // add event listeners
141
+ this.$prevMonthButton.addEventListener('click', (event) =>
142
+ this.focusPreviousMonth(event, false)
143
+ );
144
+ this.$prevYearButton.addEventListener('click', (event) =>
145
+ this.focusPreviousYear(event, false)
146
+ );
147
+ this.$nextMonthButton.addEventListener('click', (event) =>
148
+ this.focusNextMonth(event, false)
149
+ );
150
+ this.$nextYearButton.addEventListener('click', (event) =>
151
+ this.focusNextYear(event, false)
152
+ );
153
+ this.$cancelButton.addEventListener('click', (event) => {
154
+ event.preventDefault();
155
+ this.closeDialog(event);
156
+ });
157
+ this.$okButton.addEventListener('click', () => {
158
+ this.selectDate(this.currentDate);
159
+ });
160
+
161
+ const dialogButtons = this.$dialog.querySelectorAll(
162
+ 'button:not([disabled="true"])'
163
+ );
164
+ // eslint-disable-next-line prefer-destructuring
165
+ this.$firstButtonInDialog = dialogButtons[0];
166
+ this.$lastButtonInDialog = dialogButtons[dialogButtons.length - 1];
167
+ this.$firstButtonInDialog.addEventListener('keydown', (event) =>
168
+ this.firstButtonKeydown(event)
169
+ );
170
+ this.$lastButtonInDialog.addEventListener('keydown', (event) =>
171
+ this.lastButtonKeydown(event)
172
+ );
173
+
174
+ this.$calendarButton.addEventListener('click', (event) =>
175
+ this.toggleDialog(event)
176
+ );
177
+
178
+ this.$dialog.addEventListener('keydown', (event) => {
179
+ if (event.key === 'Escape') {
180
+ this.closeDialog();
181
+ event.preventDefault();
182
+ event.stopPropagation();
183
+ }
184
+ });
185
+
186
+ document.body.addEventListener('mouseup', (event) =>
187
+ this.backgroundClick(event)
188
+ );
189
+
190
+ // populates calendar with initial dates, avoids Wave errors about null buttons
191
+ this.updateCalendar();
192
+ };
193
+
194
+ DatePicker.prototype.createDialog = function () {
195
+ const titleId = `datepicker-title-${this.$input.id}`;
196
+ const $dialog = document.createElement('div');
197
+
198
+ $dialog.id = this.id;
199
+ $dialog.setAttribute('class', 'moj-datepicker__dialog');
200
+ $dialog.setAttribute('role', 'dialog');
201
+ $dialog.setAttribute('aria-modal', 'true');
202
+ $dialog.setAttribute('aria-labelledby', titleId);
203
+ $dialog.innerHTML = this.dialogTemplate(titleId);
204
+ $dialog.hidden = true;
205
+
206
+ return $dialog
207
+ };
208
+
209
+ DatePicker.prototype.createCalendar = function () {
210
+ const $tbody = this.$dialog.querySelector('tbody');
211
+ let dayCount = 0;
212
+ for (let i = 0; i < 6; i++) {
213
+ // create row
214
+ const $row = $tbody.insertRow(i);
215
+
216
+ for (let j = 0; j < 7; j++) {
217
+ // create cell (day)
218
+ const $cell = document.createElement('td');
219
+ const $dateButton = document.createElement('button');
220
+
221
+ $cell.appendChild($dateButton);
222
+ $row.appendChild($cell);
223
+
224
+ const calendarDay = new DSCalendarDay($dateButton, dayCount, i, j, this);
225
+ calendarDay.init();
226
+ this.calendarDays.push(calendarDay);
227
+ dayCount++;
228
+ }
229
+ }
230
+ };
231
+
232
+ DatePicker.prototype.toggleTemplate = function () {
233
+ return `<button class="moj-datepicker__toggle moj-js-datepicker-toggle" type="button" aria-haspopup="dialog" aria-controls="${this.id}" aria-expanded="false">
234
+ <span class="govuk-visually-hidden">Choose date</span>
235
+ <svg width="32" height="24" focusable="false" class="moj-datepicker-icon" aria-hidden="true" role="img" viewBox="0 0 22 22">
236
+ <path
237
+ fill="currentColor"
238
+ fill-rule="evenodd"
239
+ clip-rule="evenodd"
240
+ d="M16.1333 2.93333H5.86668V4.4C5.86668 5.21002 5.21003 5.86667 4.40002 5.86667C3.59 5.86667 2.93335 5.21002 2.93335 4.4V2.93333H2C0.895431 2.93333 0 3.82877 0 4.93334V19.2667C0 20.3712 0.89543 21.2667 2 21.2667H20C21.1046 21.2667 22 20.3712 22 19.2667V4.93333C22 3.82876 21.1046 2.93333 20 2.93333H19.0667V4.4C19.0667 5.21002 18.41 5.86667 17.6 5.86667C16.79 5.86667 16.1333 5.21002 16.1333 4.4V2.93333ZM20.5333 8.06667H1.46665V18.8C1.46665 19.3523 1.91436 19.8 2.46665 19.8H19.5333C20.0856 19.8 20.5333 19.3523 20.5333 18.8V8.06667Z"
241
+ ></path>
242
+ <rect x="3.66669" width="1.46667" height="5.13333" rx="0.733333" fill="currentColor"></rect>
243
+ <rect x="16.8667" width="1.46667" height="5.13333" rx="0.733333" fill="currentColor"></rect>
244
+ </svg>
245
+ </button>`
246
+ };
247
+
248
+ /**
249
+ * HTML template for calendar dialog
250
+ *
251
+ * @param {string} [titleId] - Id attribute for dialog title
252
+ * @returns {string}
253
+ */
254
+ DatePicker.prototype.dialogTemplate = function (titleId) {
255
+ return `<div class="moj-datepicker__dialog-header">
256
+ <div class="moj-datepicker__dialog-navbuttons">
257
+ <button class="moj-datepicker__button moj-js-datepicker-prev-year">
258
+ <span class="govuk-visually-hidden">Previous year</span>
259
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" fill="none" focusable="false" aria-hidden="true" role="img">
260
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M23.1643 20L28.9572 14.2071L27.5429 12.7929L20.3358 20L27.5429 27.2071L28.9572 25.7929L23.1643 20Z" fill="currentColor"/>
261
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M17.1643 20L22.9572 14.2071L21.5429 12.7929L14.3358 20L21.5429 27.2071L22.9572 25.7929L17.1643 20Z" fill="currentColor"/>
262
+ </svg>
263
+ </button>
264
+
265
+ <button class="moj-datepicker__button moj-js-datepicker-prev-month">
266
+ <span class="govuk-visually-hidden">Previous month</span>
267
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" focusable="false" aria-hidden="true" role="img">
268
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M20.5729 20L25.7865 14.2071L24.5137 12.7929L18.0273 20L24.5137 27.2071L25.7865 25.7929L20.5729 20Z" fill="currentColor"/>
269
+ </svg>
270
+ </button>
271
+ </div>
272
+
273
+ <h2 id="${titleId}" class="moj-datepicker__dialog-title moj-js-datepicker-month-year" aria-live="polite">June 2020</h2>
274
+
275
+ <div class="moj-datepicker__dialog-navbuttons">
276
+ <button class="moj-datepicker__button moj-js-datepicker-next-month">
277
+ <span class="govuk-visually-hidden">Next month</span>
278
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" focusable="false" aria-hidden="true" role="img">
279
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M23.4271 20L18.2135 14.2071L19.4863 12.7929L25.9727 20L19.4863 27.2071L18.2135 25.7929L23.4271 20Z" fill="currentColor"/>
280
+ </svg>
281
+ </button>
282
+
283
+ <button class="moj-datepicker__button moj-js-datepicker-next-year">
284
+ <span class="govuk-visually-hidden">Next year</span>
285
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" fill="none" focusable="false" aria-hidden="true" role="img">
286
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M20.8357 20L15.0428 14.2071L16.4571 12.7929L23.6642 20L16.4571 27.2071L15.0428 25.7929L20.8357 20Z" fill="currentColor"/>
287
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M26.8357 20L21.0428 14.2071L22.4571 12.7929L29.6642 20L22.4571 27.2071L21.0428 25.7929L26.8357 20Z" fill="currentColor"/>
288
+ </svg>
289
+ </button>
290
+ </div>
291
+ </div>
292
+
293
+ <table class="moj-datepicker__calendar moj-js-datepicker-grid" role="grid" aria-labelledby="${titleId}">
294
+ <thead>
295
+ <tr></tr>
296
+ </thead>
297
+
298
+ <tbody></tbody>
299
+ </table>
300
+
301
+ <div class="govuk-button-group">
302
+ <button type="button" class="govuk-button moj-js-datepicker-ok">Select</button>
303
+ <button type="button" class="govuk-button govuk-button--secondary moj-js-datepicker-cancel">Close</button>
304
+ </div>`
305
+ };
306
+
307
+ DatePicker.prototype.createCalendarHeaders = function () {
308
+ this.dayLabels.forEach((day) => {
309
+ const html = `<th scope="col"><span aria-hidden="true">${day.substring(0, 3)}</span><span class="govuk-visually-hidden">${day}</span></th>`;
310
+ const $headerRow = this.$dialog.querySelector('thead > tr');
311
+ $headerRow.insertAdjacentHTML('beforeend', html);
312
+ });
313
+ };
314
+
315
+ /**
316
+ * Pads given number with leading zeros
317
+ *
318
+ * @param {number} value - The value to be padded
319
+ * @param {number} length - The length in characters of the output
320
+ * @returns {string}
321
+ */
322
+ DatePicker.prototype.leadingZeros = function (value, length = 2) {
323
+ let ret = value.toString();
324
+
325
+ while (ret.length < length) {
326
+ ret = `0${ret}`;
327
+ }
328
+
329
+ return ret
330
+ };
331
+
332
+ DatePicker.prototype.setOptions = function () {
333
+ this.setMinAndMaxDatesOnCalendar();
334
+ this.setExcludedDates();
335
+ this.setExcludedDays();
336
+ this.setLeadingZeros();
337
+ this.setWeekStartDay();
338
+ };
339
+
340
+ DatePicker.prototype.setMinAndMaxDatesOnCalendar = function () {
341
+ if (this.config.minDate) {
342
+ this.minDate = this.formattedDateFromString(this.config.minDate, null);
343
+ if (this.minDate && this.currentDate < this.minDate) {
344
+ this.currentDate = this.minDate;
345
+ }
346
+ }
347
+
348
+ if (this.config.maxDate) {
349
+ this.maxDate = this.formattedDateFromString(this.config.maxDate, null);
350
+ if (this.maxDate && this.currentDate > this.maxDate) {
351
+ this.currentDate = this.maxDate;
352
+ }
353
+ }
354
+ };
355
+
356
+ DatePicker.prototype.setExcludedDates = function () {
357
+ if (this.config.excludedDates) {
358
+ this.excludedDates = this.config.excludedDates
359
+ .replace(/\s+/, ' ')
360
+ .split(' ')
361
+ .map((item) => {
362
+ return item.includes('-')
363
+ ? this.parseDateRangeString(item)
364
+ : this.formattedDateFromString(item)
365
+ })
366
+ .flat()
367
+ .filter((item) => item);
368
+ }
369
+ };
370
+
371
+ /*
372
+ * Parses a daterange string into an array of dates
373
+ * @param {String} datestring - A daterange string in the format "dd/mm/yyyy-dd/mm/yyyy"
374
+ * @returns {Date[]}
375
+ */
376
+ DatePicker.prototype.parseDateRangeString = function (datestring) {
377
+ const dates = [];
378
+ const [startDate, endDate] = datestring
379
+ .split('-')
380
+ .map((d) => this.formattedDateFromString(d, null));
381
+
382
+ if (startDate && endDate) {
383
+ const date = new Date(startDate.getTime());
384
+ /* eslint-disable no-unmodified-loop-condition */
385
+ while (date <= endDate) {
386
+ dates.push(new Date(date));
387
+ date.setDate(date.getDate() + 1);
388
+ }
389
+ /* eslint-enable no-unmodified-loop-condition */
390
+ }
391
+ return dates
392
+ };
393
+
394
+ DatePicker.prototype.setExcludedDays = function () {
395
+ if (this.config.excludedDays) {
396
+ // lowercase and arrange dayLabels to put indexOf sunday == 0 for comparison
397
+ // with getDay() function
398
+ const weekDays = this.dayLabels.map((item) => item.toLowerCase());
399
+ if (this.config.weekStartDay === 'monday') {
400
+ weekDays.unshift(weekDays.pop());
401
+ }
402
+
403
+ this.excludedDays = this.config.excludedDays
404
+ .replace(/\s+/, ' ')
405
+ .toLowerCase()
406
+ .split(' ')
407
+ .map((item) => weekDays.indexOf(item))
408
+ .filter((item) => item !== -1);
409
+ }
410
+ };
411
+
412
+ DatePicker.prototype.setLeadingZeros = function () {
413
+ if (typeof this.config.leadingZeros !== 'boolean') {
414
+ if (this.config.leadingZeros.toLowerCase() === 'true') {
415
+ this.config.leadingZeros = true;
416
+ return
417
+ }
418
+ if (this.config.leadingZeros.toLowerCase() === 'false') {
419
+ this.config.leadingZeros = false;
420
+ }
421
+ }
422
+ };
423
+
424
+ DatePicker.prototype.setWeekStartDay = function () {
425
+ const weekStartDayParam = this.config.weekStartDay;
426
+ if (weekStartDayParam && weekStartDayParam.toLowerCase() === 'sunday') {
427
+ this.config.weekStartDay = 'sunday';
428
+ // Rotate dayLabels array to put Sunday as the first item
429
+ this.dayLabels.unshift(this.dayLabels.pop());
430
+ } else {
431
+ this.config.weekStartDay = 'monday';
432
+ }
433
+ };
434
+
435
+ /**
436
+ * Determine if a date is selecteable
437
+ *
438
+ * @param {Date} date - the date to check
439
+ * @returns {boolean}
440
+ */
441
+ DatePicker.prototype.isExcludedDate = function (date) {
442
+ // This comparison does not work correctly - it will exclude the mindate itself
443
+ // see: https://github.com/ministryofjustice/moj-frontend/issues/923
444
+ if (this.minDate && this.minDate > date) {
445
+ return true
446
+ }
447
+
448
+ // This comparison works as expected - the maxdate will not be excluded
449
+ if (this.maxDate && this.maxDate < date) {
450
+ return true
451
+ }
452
+
453
+ for (const excludedDate of this.excludedDates) {
454
+ if (date.toDateString() === excludedDate.toDateString()) {
455
+ return true
456
+ }
457
+ }
458
+
459
+ if (this.excludedDays.includes(date.getDay())) {
460
+ return true
461
+ }
462
+
463
+ return false
464
+ };
465
+
466
+ /**
467
+ * Get a Date object from a string
468
+ *
469
+ * @param {string} dateString - string in the format d/m/yyyy dd/mm/yyyy
470
+ * @param {Date} fallback - date object to return if formatting fails
471
+ * @returns {Date}
472
+ */
473
+ DatePicker.prototype.formattedDateFromString = function (
474
+ dateString,
475
+ fallback = new Date()
476
+ ) {
477
+ let formattedDate = null;
478
+ // Accepts d/m/yyyy and dd/mm/yyyy
479
+ const dateFormatPattern = /(\d{1,2})([-/,. ])(\d{1,2})\2(\d{4})/;
480
+
481
+ if (!dateFormatPattern.test(dateString)) return fallback
482
+
483
+ const match = dateString.match(dateFormatPattern);
484
+ const day = match[1];
485
+ const month = match[3];
486
+ const year = match[4];
487
+
488
+ formattedDate = new Date(`${year}-${month}-${day}`);
489
+ if (formattedDate instanceof Date && !isNaN(formattedDate)) {
490
+ return formattedDate
491
+ }
492
+ return fallback
493
+ };
494
+
495
+ /**
496
+ * Get a formatted date string from a Date object
497
+ *
498
+ * @param {Date} date - date to format to a string
499
+ * @returns {string}
500
+ */
501
+ DatePicker.prototype.formattedDateFromDate = function (date) {
502
+ if (this.config.leadingZeros) {
503
+ return `${this.leadingZeros(date.getDate())}/${this.leadingZeros(date.getMonth() + 1)}/${date.getFullYear()}`
504
+ }
505
+
506
+ return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`
507
+ };
508
+
509
+ /**
510
+ * Get a human readable date in the format Monday 2 March 2024
511
+ *
512
+ * @param {Date} date - date to format
513
+ * @returns {string}
514
+ */
515
+ DatePicker.prototype.formattedDateHuman = function (date) {
516
+ return `${this.dayLabels[(date.getDay() + 6) % 7]} ${date.getDate()} ${this.monthLabels[date.getMonth()]} ${date.getFullYear()}`
517
+ };
518
+
519
+ DatePicker.prototype.backgroundClick = function (event) {
520
+ if (
521
+ this.isOpen() &&
522
+ !this.$dialog.contains(event.target) &&
523
+ !this.$input.contains(event.target) &&
524
+ !this.$calendarButton.contains(event.target)
525
+ ) {
526
+ event.preventDefault();
527
+ this.closeDialog();
528
+ }
529
+ };
530
+
531
+ DatePicker.prototype.firstButtonKeydown = function (event) {
532
+ if (event.key === 'Tab' && event.shiftKey) {
533
+ this.$lastButtonInDialog.focus();
534
+ event.preventDefault();
535
+ }
536
+ };
537
+
538
+ DatePicker.prototype.lastButtonKeydown = function (event) {
539
+ if (event.key === 'Tab' && !event.shiftKey) {
540
+ this.$firstButtonInDialog.focus();
541
+ event.preventDefault();
542
+ }
543
+ };
544
+
545
+ // render calendar
546
+ DatePicker.prototype.updateCalendar = function () {
547
+ this.$dialogTitle.innerHTML = `${this.monthLabels[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`;
548
+
549
+ const day = this.currentDate;
550
+ const firstOfMonth = new Date(day.getFullYear(), day.getMonth(), 1);
551
+ let dayOfWeek;
552
+
553
+ if (this.config.weekStartDay === 'monday') {
554
+ dayOfWeek = firstOfMonth.getDay() === 0 ? 6 : firstOfMonth.getDay() - 1; // Change logic to make Monday first day of week, i.e. 0
555
+ } else {
556
+ dayOfWeek = firstOfMonth.getDay();
557
+ }
558
+
559
+ firstOfMonth.setDate(firstOfMonth.getDate() - dayOfWeek);
560
+
561
+ const thisDay = new Date(firstOfMonth);
562
+
563
+ // loop through our days
564
+ for (let i = 0; i < this.calendarDays.length; i++) {
565
+ const hidden = thisDay.getMonth() !== day.getMonth();
566
+ const disabled = this.isExcludedDate(thisDay);
567
+
568
+ this.calendarDays[i].update(thisDay, hidden, disabled);
569
+
570
+ thisDay.setDate(thisDay.getDate() + 1);
571
+ }
572
+ };
573
+
574
+ DatePicker.prototype.setCurrentDate = function (focus = true) {
575
+ const { currentDate } = this;
576
+ this.calendarDays.forEach((calendarDay) => {
577
+ calendarDay.button.classList.add('moj-datepicker__button');
578
+ calendarDay.button.classList.add('moj-datepicker__calendar-day');
579
+ calendarDay.button.setAttribute('tabindex', -1);
580
+ calendarDay.button.classList.remove(this.selectedDayButtonClass);
581
+ const calendarDayDate = calendarDay.date;
582
+ calendarDayDate.setHours(0, 0, 0, 0);
583
+
584
+ const today = new Date();
585
+ today.setHours(0, 0, 0, 0);
586
+
587
+ if (
588
+ calendarDayDate.getTime() ===
589
+ currentDate.getTime() /* && !calendarDay.button.disabled */
590
+ ) {
591
+ if (focus) {
592
+ calendarDay.button.setAttribute('tabindex', 0);
593
+ calendarDay.button.focus();
594
+ calendarDay.button.classList.add(this.selectedDayButtonClass);
595
+ }
596
+ }
597
+
598
+ if (
599
+ this.inputDate &&
600
+ calendarDayDate.getTime() === this.inputDate.getTime()
601
+ ) {
602
+ calendarDay.button.classList.add(this.currentDayButtonClass);
603
+ calendarDay.button.setAttribute('aria-current', 'date');
604
+ } else {
605
+ calendarDay.button.classList.remove(this.currentDayButtonClass);
606
+ calendarDay.button.removeAttribute('aria-current');
607
+ }
608
+
609
+ if (calendarDayDate.getTime() === today.getTime()) {
610
+ calendarDay.button.classList.add(this.todayButtonClass);
611
+ } else {
612
+ calendarDay.button.classList.remove(this.todayButtonClass);
613
+ }
614
+ });
615
+
616
+ // if no date is tab-able, make the first non-disabled date tab-able
617
+ if (!focus) {
618
+ const enabledDays = this.calendarDays.filter((calendarDay) => {
619
+ return (
620
+ window.getComputedStyle(calendarDay.button).display === 'block' &&
621
+ !calendarDay.button.disabled
622
+ )
623
+ });
624
+
625
+ enabledDays[0].button.setAttribute('tabindex', 0);
626
+
627
+ this.currentDate = enabledDays[0].date;
628
+ }
629
+ };
630
+
631
+ DatePicker.prototype.selectDate = function (date) {
632
+ if (this.isExcludedDate(date)) {
633
+ return
634
+ }
635
+
636
+ this.$calendarButton.querySelector('span').innerText =
637
+ `Choose date. Selected date is ${this.formattedDateHuman(date)}`;
638
+ this.$input.value = this.formattedDateFromDate(date);
639
+
640
+ const changeEvent = new Event('change', { bubbles: true, cancelable: true });
641
+ this.$input.dispatchEvent(changeEvent);
642
+
643
+ this.closeDialog();
644
+ };
645
+
646
+ DatePicker.prototype.isOpen = function () {
647
+ return this.$dialog.classList.contains('moj-datepicker__dialog--open')
648
+ };
649
+
650
+ DatePicker.prototype.toggleDialog = function (event) {
651
+ event.preventDefault();
652
+ if (this.isOpen()) {
653
+ this.closeDialog();
654
+ } else {
655
+ this.setMinAndMaxDatesOnCalendar();
656
+ this.openDialog();
657
+ }
658
+ };
659
+
660
+ DatePicker.prototype.openDialog = function () {
661
+ this.$dialog.hidden = false;
662
+ this.$dialog.classList.add('moj-datepicker__dialog--open');
663
+ this.$calendarButton.setAttribute('aria-expanded', 'true');
664
+
665
+ // position the dialog
666
+ // if input is wider than dialog pin it to the right
667
+ if (this.$input.offsetWidth > this.$dialog.offsetWidth) {
668
+ this.$dialog.style.right = `0px`;
669
+ }
670
+ this.$dialog.style.top = `${this.$input.offsetHeight + 3}px`;
671
+
672
+ // get the date from the input element
673
+ this.inputDate = this.formattedDateFromString(this.$input.value);
674
+ this.currentDate = this.inputDate;
675
+ this.currentDate.setHours(0, 0, 0, 0);
676
+
677
+ this.updateCalendar();
678
+ this.setCurrentDate();
679
+ };
680
+
681
+ DatePicker.prototype.closeDialog = function () {
682
+ this.$dialog.hidden = true;
683
+ this.$dialog.classList.remove('moj-datepicker__dialog--open');
684
+ this.$calendarButton.setAttribute('aria-expanded', 'false');
685
+ this.$calendarButton.focus();
686
+ };
687
+
688
+ DatePicker.prototype.goToDate = function (date, focus) {
689
+ const current = this.currentDate;
690
+ this.currentDate = date;
691
+
692
+ if (
693
+ current.getMonth() !== this.currentDate.getMonth() ||
694
+ current.getFullYear() !== this.currentDate.getFullYear()
695
+ ) {
696
+ this.updateCalendar();
697
+ }
698
+
699
+ this.setCurrentDate(focus);
700
+ };
701
+
702
+ // day navigation
703
+ DatePicker.prototype.focusNextDay = function () {
704
+ const date = new Date(this.currentDate);
705
+ date.setDate(date.getDate() + 1);
706
+ this.goToDate(date);
707
+ };
708
+
709
+ DatePicker.prototype.focusPreviousDay = function () {
710
+ const date = new Date(this.currentDate);
711
+ date.setDate(date.getDate() - 1);
712
+ this.goToDate(date);
713
+ };
714
+
715
+ // week navigation
716
+ DatePicker.prototype.focusNextWeek = function () {
717
+ const date = new Date(this.currentDate);
718
+ date.setDate(date.getDate() + 7);
719
+ this.goToDate(date);
720
+ };
721
+
722
+ DatePicker.prototype.focusPreviousWeek = function () {
723
+ const date = new Date(this.currentDate);
724
+ date.setDate(date.getDate() - 7);
725
+ this.goToDate(date);
726
+ };
727
+
728
+ DatePicker.prototype.focusFirstDayOfWeek = function () {
729
+ const date = new Date(this.currentDate);
730
+ const firstDayOfWeekIndex = this.config.weekStartDay === 'sunday' ? 0 : 1;
731
+ const dayOfWeek = date.getDay();
732
+ const diff =
733
+ dayOfWeek >= firstDayOfWeekIndex
734
+ ? dayOfWeek - firstDayOfWeekIndex
735
+ : 6 - dayOfWeek;
736
+
737
+ date.setDate(date.getDate() - diff);
738
+ date.setHours(0, 0, 0, 0);
739
+
740
+ this.goToDate(date);
741
+ };
742
+
743
+ DatePicker.prototype.focusLastDayOfWeek = function () {
744
+ const date = new Date(this.currentDate);
745
+ const lastDayOfWeekIndex = this.config.weekStartDay === 'sunday' ? 6 : 0;
746
+ const dayOfWeek = date.getDay();
747
+ const diff =
748
+ dayOfWeek <= lastDayOfWeekIndex
749
+ ? lastDayOfWeekIndex - dayOfWeek
750
+ : 7 - dayOfWeek;
751
+
752
+ date.setDate(date.getDate() + diff);
753
+ date.setHours(0, 0, 0, 0);
754
+
755
+ this.goToDate(date);
756
+ };
757
+
758
+ // month navigation
759
+ DatePicker.prototype.focusNextMonth = function (event, focus = true) {
760
+ event.preventDefault();
761
+ const date = new Date(this.currentDate);
762
+ date.setMonth(date.getMonth() + 1, 1);
763
+ this.goToDate(date, focus);
764
+ };
765
+
766
+ DatePicker.prototype.focusPreviousMonth = function (event, focus = true) {
767
+ event.preventDefault();
768
+ const date = new Date(this.currentDate);
769
+ date.setMonth(date.getMonth() - 1, 1);
770
+ this.goToDate(date, focus);
771
+ };
772
+
773
+ // year navigation
774
+ DatePicker.prototype.focusNextYear = function (event, focus = true) {
775
+ event.preventDefault();
776
+ const date = new Date(this.currentDate);
777
+ date.setFullYear(date.getFullYear() + 1, date.getMonth(), 1);
778
+ this.goToDate(date, focus);
779
+ };
780
+
781
+ DatePicker.prototype.focusPreviousYear = function (event, focus = true) {
782
+ event.preventDefault();
783
+ const date = new Date(this.currentDate);
784
+ date.setFullYear(date.getFullYear() - 1, date.getMonth(), 1);
785
+ this.goToDate(date, focus);
786
+ };
787
+
788
+ /**
789
+ * Parse dataset
790
+ *
791
+ * @param {Schema} schema - Component class
792
+ * @param {DOMStringMap} dataset - HTML element dataset
793
+ * @returns {object} Normalised dataset
794
+ */
795
+ DatePicker.prototype.parseDataset = function (schema, dataset) {
796
+ const parsed = {};
797
+
798
+ for (const [field, ,] of Object.entries(schema.properties)) {
799
+ if (field in dataset) {
800
+ parsed[field] = dataset[field];
801
+ }
802
+ }
803
+
804
+ return parsed
805
+ };
806
+
807
+ /**
808
+ * Config merging function
809
+ *
810
+ * Takes any number of objects and combines them together, with
811
+ * greatest priority on the LAST item passed in.
812
+ *
813
+ * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
814
+ * @returns {{ [key: string]: unknown }} A merged config object
815
+ */
816
+ DatePicker.prototype.mergeConfigs = function (...configObjects) {
817
+ const formattedConfigObject = {};
818
+
819
+ // Loop through each of the passed objects
820
+ for (const configObject of configObjects) {
821
+ for (const key of Object.keys(configObject)) {
822
+ const option = formattedConfigObject[key];
823
+ const override = configObject[key];
824
+
825
+ // Push their keys one-by-one into formattedConfigObject. Any duplicate
826
+ // keys with object values will be merged, otherwise the new value will
827
+ // override the existing value.
828
+ if (typeof option === 'object' && typeof override === 'object') {
829
+ // @ts-expect-error Index signature for type 'string' is missing
830
+ formattedConfigObject[key] = this.mergeConfigs(option, override);
831
+ } else {
832
+ formattedConfigObject[key] = override;
833
+ }
834
+ }
835
+ }
836
+
837
+ return formattedConfigObject
838
+ };
839
+
840
+ /**
841
+ *
842
+ * @param {HTMLElement} button
843
+ * @param {number} index
844
+ * @param {number} row
845
+ * @param {number} column
846
+ * @param {DatePicker} picker
847
+ * @class
848
+ */
849
+ function DSCalendarDay(button, index, row, column, picker) {
850
+ this.index = index;
851
+ this.row = row;
852
+ this.column = column;
853
+ this.button = button;
854
+ this.picker = picker;
855
+
856
+ this.date = new Date();
857
+ }
858
+
859
+ DSCalendarDay.prototype.init = function () {
860
+ this.button.addEventListener('keydown', this.keyPress.bind(this));
861
+ this.button.addEventListener('click', this.click.bind(this));
862
+ };
863
+
864
+ /**
865
+ * @param {Date} day - the Date for the calendar day
866
+ * @param {boolean} hidden - visibility of the day
867
+ * @param {boolean} disabled - is the day selectable or excluded
868
+ */
869
+ DSCalendarDay.prototype.update = function (day, hidden, disabled) {
870
+ const label = day.getDate();
871
+ let accessibleLabel = this.picker.formattedDateHuman(day);
872
+
873
+ if (disabled) {
874
+ this.button.setAttribute('aria-disabled', true);
875
+ accessibleLabel = `Excluded date, ${accessibleLabel}`;
876
+ } else {
877
+ this.button.removeAttribute('aria-disabled');
878
+ }
879
+
880
+ if (hidden) {
881
+ this.button.style.display = 'none';
882
+ } else {
883
+ this.button.style.display = 'block';
884
+ }
885
+ this.button.setAttribute(
886
+ 'data-testid',
887
+ this.picker.formattedDateFromDate(day)
888
+ );
889
+
890
+ this.button.innerHTML = `<span class="govuk-visually-hidden">${accessibleLabel}</span><span aria-hidden="true">${label}</span>`;
891
+ this.date = new Date(day);
892
+ };
893
+
894
+ DSCalendarDay.prototype.click = function (event) {
895
+ this.picker.goToDate(this.date);
896
+ this.picker.selectDate(this.date);
897
+
898
+ event.stopPropagation();
899
+ event.preventDefault();
900
+ };
901
+
902
+ DSCalendarDay.prototype.keyPress = function (event) {
903
+ let calendarNavKey = true;
904
+
905
+ switch (event.key) {
906
+ case 'ArrowLeft':
907
+ this.picker.focusPreviousDay();
908
+ break
909
+ case 'ArrowRight':
910
+ this.picker.focusNextDay();
911
+ break
912
+ case 'ArrowUp':
913
+ this.picker.focusPreviousWeek();
914
+ break
915
+ case 'ArrowDown':
916
+ this.picker.focusNextWeek();
917
+ break
918
+ case 'Home':
919
+ this.picker.focusFirstDayOfWeek();
920
+ break
921
+ case 'End':
922
+ this.picker.focusLastDayOfWeek();
923
+ break
924
+ case 'PageUp':
925
+ // eslint-disable-next-line no-unused-expressions
926
+ event.shiftKey
927
+ ? this.picker.focusPreviousYear(event)
928
+ : this.picker.focusPreviousMonth(event);
929
+ break
930
+ case 'PageDown':
931
+ // eslint-disable-next-line no-unused-expressions
932
+ event.shiftKey
933
+ ? this.picker.focusNextYear(event)
934
+ : this.picker.focusNextMonth(event);
935
+ break
936
+ default:
937
+ calendarNavKey = false;
938
+ break
939
+ }
940
+
941
+ if (calendarNavKey) {
942
+ event.preventDefault();
943
+ event.stopPropagation();
944
+ }
945
+ };
946
+
947
+ /**
948
+ * Schema for component config
949
+ *
950
+ * @typedef {object} Schema
951
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
952
+ */
953
+
954
+ /**
955
+ * Schema property for component config
956
+ *
957
+ * @typedef {object} SchemaProperty
958
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
959
+ */
960
+
961
+ export { DatePicker };