@ministryofjustice/frontend 2.1.3 → 2.2.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
@@ -128,6 +128,11 @@ MOJFrontend.initAll = function (options) {
128
128
  table: $table
129
129
  });
130
130
  });
131
+
132
+ const $datepickers = document.querySelectorAll('[data-module="moj-date-picker"]')
133
+ MOJFrontend.nodeListForEach($datepickers, function ($datepicker) {
134
+ new MOJFrontend.DatePicker($datepicker, {}).init();
135
+ })
131
136
  }
132
137
 
133
138
  MOJFrontend.AddAnother = function(container) {
@@ -370,6 +375,940 @@ MOJFrontend.ButtonMenu.prototype.focusPrevious = function(currentButton) {
370
375
  }
371
376
  };
372
377
 
378
+ /**
379
+ * Datepicker config
380
+ *
381
+ * @typedef {object} DatepickerConfig
382
+ * @property {string} [excludedDates] - Dates that cannot be selected
383
+ * @property {string} [excludedDays] - Days that cannot be selected
384
+ * @property {boolean} [leadingZeroes] - Whether to add leading zeroes when populating the field
385
+ * @property {string} [minDate] - The earliest available date
386
+ * @property {string} [maxDate] - The latest available date
387
+ * @property {string} [weekStartDay] - First day of the week in calendar view
388
+ */
389
+
390
+ /**
391
+ * @param {HTMLElement} $module - HTML element
392
+ * @param {DatepickerConfig} config - config object
393
+ * @constructor
394
+ */
395
+ function Datepicker($module, config) {
396
+ if (!$module) {
397
+ return this;
398
+ }
399
+
400
+ const schema = Object.freeze({
401
+ properties: {
402
+ excludedDates: { type: "string" },
403
+ excludedDays: { type: "string" },
404
+ leadingZeros: { type: "string" },
405
+ maxDate: { type: "string" },
406
+ minDate: { type: "string" },
407
+ weekStartDay: { type: "string" },
408
+ },
409
+ });
410
+
411
+ const defaults = {
412
+ leadingZeros: false,
413
+ weekStartDay: "monday",
414
+ };
415
+
416
+ // data attributes override JS config, which overrides defaults
417
+ this.config = this.mergeConfigs(
418
+ defaults,
419
+ config,
420
+ this.parseDataset(schema, $module.dataset),
421
+ );
422
+
423
+ this.dayLabels = [
424
+ "Monday",
425
+ "Tuesday",
426
+ "Wednesday",
427
+ "Thursday",
428
+ "Friday",
429
+ "Saturday",
430
+ "Sunday",
431
+ ];
432
+
433
+ this.monthLabels = [
434
+ "January",
435
+ "February",
436
+ "March",
437
+ "April",
438
+ "May",
439
+ "June",
440
+ "July",
441
+ "August",
442
+ "September",
443
+ "October",
444
+ "November",
445
+ "December",
446
+ ];
447
+
448
+ this.currentDate = new Date();
449
+ this.currentDate.setHours(0, 0, 0, 0);
450
+ this.calendarDays = [];
451
+ this.excludedDates = [];
452
+ this.excludedDays = [];
453
+
454
+ this.buttonClass = "moj-datepicker__button";
455
+ this.selectedDayButtonClass = "moj-datepicker__button--selected";
456
+ this.currentDayButtonClass = "moj-datepicker__button--current";
457
+ this.todayButtonClass = "moj-datepicker__button--today";
458
+
459
+ this.$module = $module;
460
+ this.$input = $module.querySelector(".moj-js-datepicker-input");
461
+ }
462
+
463
+ Datepicker.prototype.init = function () {
464
+ // Check that required elements are present
465
+ if (!this.$input) {
466
+ return;
467
+ }
468
+
469
+ this.setOptions();
470
+ this.initControls();
471
+ };
472
+
473
+ Datepicker.prototype.initControls = function () {
474
+ this.id = `datepicker-${this.$input.id}`;
475
+
476
+ this.$dialog = this.createDialog();
477
+ this.createCalendarHeaders();
478
+
479
+ const $componentWrapper = document.createElement("div");
480
+ const $inputWrapper = document.createElement("div");
481
+ $componentWrapper.classList.add("moj-datepicker__wrapper");
482
+ $inputWrapper.classList.add("govuk-input__wrapper");
483
+
484
+ this.$input.parentNode.insertBefore($componentWrapper, this.$input);
485
+ $componentWrapper.appendChild($inputWrapper);
486
+ $inputWrapper.appendChild(this.$input);
487
+
488
+ $inputWrapper.insertAdjacentHTML("beforeend", this.toggleTemplate());
489
+ $componentWrapper.insertAdjacentElement("beforeend", this.$dialog);
490
+
491
+ this.$calendarButton = this.$module.querySelector(
492
+ ".moj-js-datepicker-toggle",
493
+ );
494
+ this.$dialogTitle = this.$dialog.querySelector(
495
+ ".moj-js-datepicker-month-year",
496
+ );
497
+
498
+ this.createCalendar();
499
+
500
+ this.$prevMonthButton = this.$dialog.querySelector(
501
+ ".moj-js-datepicker-prev-month",
502
+ );
503
+ this.$prevYearButton = this.$dialog.querySelector(
504
+ ".moj-js-datepicker-prev-year",
505
+ );
506
+ this.$nextMonthButton = this.$dialog.querySelector(
507
+ ".moj-js-datepicker-next-month",
508
+ );
509
+ this.$nextYearButton = this.$dialog.querySelector(
510
+ ".moj-js-datepicker-next-year",
511
+ );
512
+ this.$cancelButton = this.$dialog.querySelector(".moj-js-datepicker-cancel");
513
+ this.$okButton = this.$dialog.querySelector(".moj-js-datepicker-ok");
514
+
515
+ // add event listeners
516
+ this.$prevMonthButton.addEventListener("click", (event) =>
517
+ this.focusPreviousMonth(event, false),
518
+ );
519
+ this.$prevYearButton.addEventListener("click", (event) =>
520
+ this.focusPreviousYear(event, false),
521
+ );
522
+ this.$nextMonthButton.addEventListener("click", (event) =>
523
+ this.focusNextMonth(event, false),
524
+ );
525
+ this.$nextYearButton.addEventListener("click", (event) =>
526
+ this.focusNextYear(event, false),
527
+ );
528
+ this.$cancelButton.addEventListener("click", (event) => {
529
+ event.preventDefault();
530
+ this.closeDialog(event);
531
+ });
532
+ this.$okButton.addEventListener("click", () => {
533
+ this.selectDate(this.currentDate);
534
+ });
535
+
536
+ const dialogButtons = this.$dialog.querySelectorAll(
537
+ 'button:not([disabled="true"])',
538
+ );
539
+ // eslint-disable-next-line prefer-destructuring
540
+ this.$firstButtonInDialog = dialogButtons[0];
541
+ this.$lastButtonInDialog = dialogButtons[dialogButtons.length - 1];
542
+ this.$firstButtonInDialog.addEventListener("keydown", (event) =>
543
+ this.firstButtonKeydown(event),
544
+ );
545
+ this.$lastButtonInDialog.addEventListener("keydown", (event) =>
546
+ this.lastButtonKeydown(event),
547
+ );
548
+
549
+ this.$calendarButton.addEventListener("click", (event) =>
550
+ this.toggleDialog(event),
551
+ );
552
+
553
+ this.$dialog.addEventListener("keydown", (event) => {
554
+ if (event.key == "Escape") {
555
+ this.closeDialog();
556
+ event.preventDefault();
557
+ event.stopPropagation();
558
+ }
559
+ });
560
+
561
+ document.body.addEventListener("mouseup", (event) =>
562
+ this.backgroundClick(event),
563
+ );
564
+
565
+ // populates calendar with initial dates, avoids Wave errors about null buttons
566
+ this.updateCalendar();
567
+ };
568
+
569
+ Datepicker.prototype.createDialog = function () {
570
+ const titleId = `datepicker-title-${this.$input.id}`;
571
+ const $dialog = document.createElement("div");
572
+
573
+ $dialog.id = this.id;
574
+ $dialog.setAttribute("class", "moj-datepicker__dialog");
575
+ $dialog.setAttribute("role", "dialog");
576
+ $dialog.setAttribute("aria-modal", "true");
577
+ $dialog.setAttribute("aria-labelledby", titleId);
578
+ $dialog.innerHTML = this.dialogTemplate(titleId);
579
+
580
+ return $dialog;
581
+ };
582
+
583
+ Datepicker.prototype.createCalendar = function () {
584
+ const $tbody = this.$dialog.querySelector("tbody");
585
+ let dayCount = 0;
586
+ for (let i = 0; i < 6; i++) {
587
+ // create row
588
+ const $row = $tbody.insertRow(i);
589
+
590
+ for (let j = 0; j < 7; j++) {
591
+ // create cell (day)
592
+ const $cell = document.createElement("td");
593
+ const $dateButton = document.createElement("button");
594
+
595
+ $cell.appendChild($dateButton);
596
+ $row.appendChild($cell);
597
+
598
+ const calendarDay = new DSCalendarDay($dateButton, dayCount, i, j, this);
599
+ calendarDay.init();
600
+ this.calendarDays.push(calendarDay);
601
+ dayCount++;
602
+ }
603
+ }
604
+ };
605
+
606
+ Datepicker.prototype.toggleTemplate = function () {
607
+ return `<button class="moj-datepicker__toggle moj-js-datepicker-toggle" type="button" aria-haspopup="dialog" aria-controls="${this.id}" aria-expanded="false">
608
+ <span class="govuk-visually-hidden">Choose date</span>
609
+ <svg width="32" height="24" focusable="false" class="moj-datepicker-icon" aria-hidden="true" role="img" viewBox="0 0 22 22">
610
+ <path
611
+ fill="currentColor"
612
+ fill-rule="evenodd"
613
+ clip-rule="evenodd"
614
+ 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"
615
+ ></path>
616
+ <rect x="3.66669" width="1.46667" height="5.13333" rx="0.733333" fill="currentColor"></rect>
617
+ <rect x="16.8667" width="1.46667" height="5.13333" rx="0.733333" fill="currentColor"></rect>
618
+ </svg>
619
+ </button>`;
620
+ };
621
+
622
+ /**
623
+ * HTML template for calendar dialog
624
+ *
625
+ * @param {string} [titleId] - Id attribute for dialog title
626
+ * @return {string}
627
+ */
628
+ Datepicker.prototype.dialogTemplate = function (titleId) {
629
+ return `<div class="moj-datepicker__dialog-header">
630
+ <div class="moj-datepicker__dialog-navbuttons">
631
+ <button class="moj-datepicker__button moj-js-datepicker-prev-year">
632
+ <span class="govuk-visually-hidden">Previous year</span>
633
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" fill="none" focusable="false" aria-hidden="true" role="img">
634
+ <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"/>
635
+ <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"/>
636
+ </svg>
637
+ </button>
638
+
639
+ <button class="moj-datepicker__button moj-js-datepicker-prev-month">
640
+ <span class="govuk-visually-hidden">Previous month</span>
641
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" focusable="false" aria-hidden="true" role="img">
642
+ <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"/>
643
+ </svg>
644
+ </button>
645
+ </div>
646
+
647
+ <h2 id="${titleId}" class="moj-datepicker__dialog-title moj-js-datepicker-month-year" aria-live="polite">June 2020</h2>
648
+
649
+ <div class="moj-datepicker__dialog-navbuttons">
650
+ <button class="moj-datepicker__button moj-js-datepicker-next-month">
651
+ <span class="govuk-visually-hidden">Next month</span>
652
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" focusable="false" aria-hidden="true" role="img">
653
+ <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"/>
654
+ </svg>
655
+ </button>
656
+
657
+ <button class="moj-datepicker__button moj-js-datepicker-next-year">
658
+ <span class="govuk-visually-hidden">Next year</span>
659
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" fill="none" focusable="false" aria-hidden="true" role="img">
660
+ <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"/>
661
+ <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"/>
662
+ </svg>
663
+ </button>
664
+ </div>
665
+ </div>
666
+
667
+ <table class="moj-datepicker__calendar moj-js-datepicker-grid" role="grid" aria-labelledby="${titleId}">
668
+ <thead>
669
+ <tr></tr>
670
+ </thead>
671
+
672
+ <tbody></tbody>
673
+ </table>
674
+
675
+ <div class="govuk-button-group">
676
+ <button type="button" class="govuk-button moj-js-datepicker-ok">Select</button>
677
+ <button type="button" class="govuk-button govuk-button--secondary moj-js-datepicker-cancel">Close</button>
678
+ </div>`;
679
+ };
680
+
681
+ Datepicker.prototype.createCalendarHeaders = function () {
682
+ this.dayLabels.forEach((day) => {
683
+ const html = `<th scope="col"><span aria-hidden="true">${day.substring(0, 3)}</span><span class="govuk-visually-hidden">${day}</span></th>`;
684
+ const $headerRow = this.$dialog.querySelector("thead > tr");
685
+ $headerRow.insertAdjacentHTML("beforeend", html);
686
+ });
687
+ };
688
+
689
+ /**
690
+ * Pads given number with leading zeros
691
+ *
692
+ * @param {number} value - The value to be padded
693
+ * @param {number} length - The length in characters of the output
694
+ * @return {string}
695
+ */
696
+ Datepicker.prototype.leadingZeros = function (value, length = 2) {
697
+ let ret = value.toString();
698
+
699
+ while (ret.length < length) {
700
+ ret = `0${ret}`;
701
+ }
702
+
703
+ return ret;
704
+ };
705
+
706
+ Datepicker.prototype.setOptions = function () {
707
+ this.setMinAndMaxDatesOnCalendar();
708
+ this.setExcludedDates();
709
+ this.setExcludedDays();
710
+ this.setLeadingZeros();
711
+ this.setWeekStartDay();
712
+ };
713
+
714
+ Datepicker.prototype.setMinAndMaxDatesOnCalendar = function () {
715
+ if (this.config.minDate) {
716
+ this.minDate = this.formattedDateFromString(
717
+ this.config.minDate,
718
+ null,
719
+ );
720
+ if (this.minDate && this.currentDate < this.minDate) {
721
+ this.currentDate = this.minDate;
722
+ }
723
+ }
724
+
725
+ if (this.config.maxDate) {
726
+ this.maxDate = this.formattedDateFromString(
727
+ this.config.maxDate,
728
+ null,
729
+ );
730
+ if (this.maxDate && this.currentDate > this.maxDate) {
731
+ this.currentDate = this.maxDate;
732
+ }
733
+ }
734
+ };
735
+
736
+ Datepicker.prototype.setExcludedDates = function () {
737
+ if (this.config.excludedDates) {
738
+ this.excludedDates = this.config.excludedDates
739
+ .replace(/\s+/, " ")
740
+ .split(" ")
741
+ .map((item) => {
742
+ if (item.includes("-")) {
743
+ // parse the date range from the format "dd/mm/yyyy-dd/mm/yyyy"
744
+ const [startDate, endDate] = item
745
+ .split("-")
746
+ .map((d) => this.formattedDateFromString(d, null));
747
+ if (startDate && endDate) {
748
+ const date = new Date(startDate.getTime());
749
+ const dates = [];
750
+ while (date <= endDate) {
751
+ dates.push(new Date(date));
752
+ date.setDate(date.getDate() + 1);
753
+ }
754
+ return dates;
755
+ }
756
+ } else {
757
+ return this.formattedDateFromString(item, null);
758
+ }
759
+ })
760
+ .flat()
761
+ .filter((item) => item);
762
+ }
763
+ };
764
+
765
+ Datepicker.prototype.setExcludedDays = function () {
766
+ if (this.config.excludedDays) {
767
+ // lowercase and arrange dayLabels to put indexOf sunday == 0 for comparison
768
+ // with getDay() function
769
+ let weekDays = this.dayLabels.map((item) => item.toLowerCase());
770
+ if (this.config.weekStartDay === "monday") {
771
+ weekDays.unshift(weekDays.pop());
772
+ }
773
+
774
+ this.excludedDays = this.config.excludedDays
775
+ .replace(/\s+/, " ")
776
+ .toLowerCase()
777
+ .split(" ")
778
+ .map((item) => weekDays.indexOf(item))
779
+ .filter((item) => item !== -1);
780
+ }
781
+ };
782
+
783
+ Datepicker.prototype.setLeadingZeros = function () {
784
+ if (typeof this.config.leadingZeros !== "boolean") {
785
+ if (this.config.leadingZeros.toLowerCase() === "true") {
786
+ this.config.leadingZeros = true;
787
+ }
788
+ if (this.config.leadingZeros.toLowerCase() === "false") {
789
+ this.config.leadingZeros = false;
790
+ }
791
+ }
792
+ };
793
+
794
+ Datepicker.prototype.setWeekStartDay = function () {
795
+ const weekStartDayParam = this.config.weekStartDay;
796
+ if (weekStartDayParam?.toLowerCase() === "sunday") {
797
+ this.config.weekStartDay = "sunday";
798
+ // Rotate dayLabels array to put Sunday as the first item
799
+ this.dayLabels.unshift(this.dayLabels.pop());
800
+ }
801
+ if (weekStartDayParam?.toLowerCase() === "monday") {
802
+ this.config.weekStartDay = "monday";
803
+ }
804
+ };
805
+
806
+ /**
807
+ * Determine if a date is selecteable
808
+ *
809
+ * @param {Date} date - the date to check
810
+ * @return {boolean}
811
+ *
812
+ */
813
+ Datepicker.prototype.isExcludedDate = function (date) {
814
+ if (this.minDate && this.minDate > date) {
815
+ return true;
816
+ }
817
+
818
+ if (this.maxDate && this.maxDate < date) {
819
+ return true;
820
+ }
821
+
822
+ for (const excludedDate of this.excludedDates) {
823
+ if (date.toDateString() === excludedDate.toDateString()) {
824
+ return true;
825
+ }
826
+ }
827
+
828
+ if (this.excludedDays.includes(date.getDay())) {
829
+ return true;
830
+ }
831
+
832
+ return false;
833
+ };
834
+
835
+ /**
836
+ * Get a Date object from a string
837
+ *
838
+ * @param {string} dateString - string in the format d/m/yyyy dd/mm/yyyy
839
+ * @param {Date} fallback - date object to return if formatting fails
840
+ * @return {Date}
841
+ */
842
+ Datepicker.prototype.formattedDateFromString = function (
843
+ dateString,
844
+ fallback = new Date(),
845
+ ) {
846
+ let formattedDate = null;
847
+ // Accepts d/m/yyyy and dd/mm/yyyy
848
+ const dateFormatPattern = /(\d{1,2})([-/,. ])(\d{1,2})\2(\d{4})/;
849
+
850
+ if (!dateFormatPattern.test(dateString)) return fallback;
851
+
852
+ const match = dateString.match(dateFormatPattern);
853
+ const day = match[1];
854
+ const month = match[3];
855
+ const year = match[4];
856
+
857
+ formattedDate = new Date(`${month}-${day}-${year}`);
858
+ if (formattedDate instanceof Date && !isNaN(formattedDate)) {
859
+ return formattedDate;
860
+ }
861
+ return fallback;
862
+ };
863
+
864
+ /**
865
+ * Get a formatted date string from a Date object
866
+ *
867
+ * @param {Date} date - date to format to a string
868
+ * @return {string}
869
+ */
870
+ Datepicker.prototype.formattedDateFromDate = function (date) {
871
+ if (this.config.leadingZeros) {
872
+ return `${this.leadingZeros(date.getDate())}/${this.leadingZeros(date.getMonth() + 1)}/${date.getFullYear()}`;
873
+ } else {
874
+ return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`;
875
+ }
876
+ };
877
+
878
+ /**
879
+ * Get a human readable date in the format Monday 2 March 2024
880
+ *
881
+ * @param {Date} - date to format
882
+ * @return {string}
883
+ */
884
+ Datepicker.prototype.formattedDateHuman = function (date) {
885
+ return `${this.dayLabels[(date.getDay() + 6) % 7]} ${date.getDate()} ${this.monthLabels[date.getMonth()]} ${date.getFullYear()}`;
886
+ };
887
+
888
+ Datepicker.prototype.backgroundClick = function (event) {
889
+ if (
890
+ this.isOpen() &&
891
+ !this.$dialog.contains(event.target) &&
892
+ !this.$input.contains(event.target) &&
893
+ !this.$calendarButton.contains(event.target)
894
+ ) {
895
+ event.preventDefault();
896
+ this.closeDialog();
897
+ }
898
+ };
899
+
900
+ Datepicker.prototype.firstButtonKeydown = function (event) {
901
+ if (event.key === "Tab" && event.shiftKey) {
902
+ this.$lastButtonInDialog.focus();
903
+ event.preventDefault();
904
+ }
905
+ };
906
+
907
+ Datepicker.prototype.lastButtonKeydown = function (event) {
908
+ if (event.key === "Tab" && !event.shiftKey) {
909
+ this.$firstButtonInDialog.focus();
910
+ event.preventDefault();
911
+ }
912
+ };
913
+
914
+ // render calendar
915
+ Datepicker.prototype.updateCalendar = function () {
916
+ this.$dialogTitle.innerHTML = `${this.monthLabels[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`;
917
+
918
+ const day = this.currentDate;
919
+ const firstOfMonth = new Date(day.getFullYear(), day.getMonth(), 1);
920
+ let dayOfWeek;
921
+
922
+ if (this.config.weekStartDay === "monday") {
923
+ dayOfWeek = firstOfMonth.getDay() === 0 ? 6 : firstOfMonth.getDay() - 1; // Change logic to make Monday first day of week, i.e. 0
924
+ } else {
925
+ dayOfWeek = firstOfMonth.getDay();
926
+ }
927
+
928
+ firstOfMonth.setDate(firstOfMonth.getDate() - dayOfWeek);
929
+
930
+ const thisDay = new Date(firstOfMonth);
931
+
932
+ // loop through our days
933
+ for (let i = 0; i < this.calendarDays.length; i++) {
934
+ const hidden = thisDay.getMonth() !== day.getMonth();
935
+ const disabled = this.isExcludedDate(thisDay);
936
+
937
+ this.calendarDays[i].update(thisDay, hidden, disabled);
938
+
939
+ thisDay.setDate(thisDay.getDate() + 1);
940
+ }
941
+ };
942
+
943
+ Datepicker.prototype.setCurrentDate = function (focus = true) {
944
+ const { currentDate } = this;
945
+
946
+ this.calendarDays.forEach((calendarDay) => {
947
+ calendarDay.button.classList.add("moj-datepicker__button");
948
+ calendarDay.button.classList.add("moj-datepicker__calendar-day");
949
+ calendarDay.button.setAttribute("tabindex", -1);
950
+ calendarDay.button.classList.remove(this.selectedDayButtonClass);
951
+ const calendarDayDate = calendarDay.date;
952
+ calendarDayDate.setHours(0, 0, 0, 0);
953
+
954
+ const today = new Date();
955
+ today.setHours(0, 0, 0, 0);
956
+
957
+ if (
958
+ calendarDayDate.getTime() ===
959
+ currentDate.getTime() /* && !calendarDay.button.disabled */
960
+ ) {
961
+ if (focus) {
962
+ calendarDay.button.setAttribute("tabindex", 0);
963
+ calendarDay.button.focus();
964
+ calendarDay.button.classList.add(this.selectedDayButtonClass);
965
+ }
966
+ }
967
+
968
+ if (
969
+ this.inputDate &&
970
+ calendarDayDate.getTime() === this.inputDate.getTime()
971
+ ) {
972
+ calendarDay.button.classList.add(this.currentDayButtonClass);
973
+ calendarDay.button.setAttribute("aria-selected", true);
974
+ } else {
975
+ calendarDay.button.classList.remove(this.currentDayButtonClass);
976
+ calendarDay.button.removeAttribute("aria-selected");
977
+ }
978
+
979
+ if (calendarDayDate.getTime() === today.getTime()) {
980
+ calendarDay.button.classList.add(this.todayButtonClass);
981
+ } else {
982
+ calendarDay.button.classList.remove(this.todayButtonClass);
983
+ }
984
+ });
985
+
986
+ // if no date is tab-able, make the first non-disabled date tab-able
987
+ if (!focus) {
988
+ const enabledDays = this.calendarDays.filter((calendarDay) => {
989
+ return (
990
+ window.getComputedStyle(calendarDay.button).display === "block" &&
991
+ !calendarDay.button.disabled
992
+ );
993
+ });
994
+
995
+ enabledDays[0].button.setAttribute("tabindex", 0);
996
+
997
+ this.currentDate = enabledDays[0].date;
998
+ }
999
+ };
1000
+
1001
+ Datepicker.prototype.selectDate = function (date) {
1002
+ if (this.isExcludedDate(date)) {
1003
+ return;
1004
+ }
1005
+
1006
+ this.$calendarButton.querySelector("span").innerText =
1007
+ `Choose date. Selected date is ${this.formattedDateHuman(date)}`;
1008
+ this.$input.value = this.formattedDateFromDate(date);
1009
+
1010
+ const changeEvent = new Event("change", { bubbles: true, cancelable: true });
1011
+ this.$input.dispatchEvent(changeEvent);
1012
+
1013
+ this.closeDialog();
1014
+ };
1015
+
1016
+ Datepicker.prototype.isOpen = function () {
1017
+ return this.$dialog.classList.contains("moj-datepicker__dialog--open");
1018
+ };
1019
+
1020
+ Datepicker.prototype.toggleDialog = function (event) {
1021
+ event.preventDefault();
1022
+ if (this.isOpen()) {
1023
+ this.closeDialog();
1024
+ } else {
1025
+ this.setMinAndMaxDatesOnCalendar();
1026
+ this.openDialog();
1027
+ }
1028
+ };
1029
+
1030
+ Datepicker.prototype.openDialog = function () {
1031
+ this.$dialog.classList.add("moj-datepicker__dialog--open");
1032
+ this.$calendarButton.setAttribute("aria-expanded", "true");
1033
+
1034
+ // position the dialog
1035
+ // if input is wider than dialog pin it to the right
1036
+ if (this.$input.offsetWidth > this.$dialog.offsetWidth) {
1037
+ this.$dialog.style.right = `0px`;
1038
+ }
1039
+ this.$dialog.style.top = `${this.$input.offsetHeight + 3}px`;
1040
+
1041
+ // get the date from the input element
1042
+ this.inputDate = this.formattedDateFromString(this.$input.value);
1043
+ this.currentDate = this.inputDate;
1044
+ this.currentDate.setHours(0, 0, 0, 0);
1045
+
1046
+ this.updateCalendar();
1047
+ this.setCurrentDate();
1048
+ };
1049
+
1050
+ Datepicker.prototype.closeDialog = function () {
1051
+ this.$dialog.classList.remove("moj-datepicker__dialog--open");
1052
+ this.$calendarButton.setAttribute("aria-expanded", "false");
1053
+ this.$calendarButton.focus();
1054
+ };
1055
+
1056
+ Datepicker.prototype.goToDate = function (date, focus) {
1057
+ const current = this.currentDate;
1058
+ this.currentDate = date;
1059
+
1060
+ if (
1061
+ current.getMonth() !== this.currentDate.getMonth() ||
1062
+ current.getFullYear() !== this.currentDate.getFullYear()
1063
+ ) {
1064
+ this.updateCalendar();
1065
+ }
1066
+
1067
+ this.setCurrentDate(focus);
1068
+ };
1069
+
1070
+ // day navigation
1071
+ Datepicker.prototype.focusNextDay = function () {
1072
+ const date = new Date(this.currentDate);
1073
+ date.setDate(date.getDate() + 1);
1074
+ this.goToDate(date);
1075
+ };
1076
+
1077
+ Datepicker.prototype.focusPreviousDay = function () {
1078
+ const date = new Date(this.currentDate);
1079
+ date.setDate(date.getDate() - 1);
1080
+ this.goToDate(date);
1081
+ };
1082
+
1083
+ // week navigation
1084
+ Datepicker.prototype.focusNextWeek = function () {
1085
+ const date = new Date(this.currentDate);
1086
+ date.setDate(date.getDate() + 7);
1087
+ this.goToDate(date);
1088
+ };
1089
+
1090
+ Datepicker.prototype.focusPreviousWeek = function () {
1091
+ const date = new Date(this.currentDate);
1092
+ date.setDate(date.getDate() - 7);
1093
+ this.goToDate(date);
1094
+ };
1095
+
1096
+ Datepicker.prototype.focusFirstDayOfWeek = function () {
1097
+ const date = new Date(this.currentDate);
1098
+ date.setDate(date.getDate() - date.getDay());
1099
+ this.goToDate(date);
1100
+ };
1101
+
1102
+ Datepicker.prototype.focusLastDayOfWeek = function () {
1103
+ const date = new Date(this.currentDate);
1104
+ date.setDate(date.getDate() - date.getDay() + 6);
1105
+ this.goToDate(date);
1106
+ };
1107
+
1108
+ // month navigation
1109
+ Datepicker.prototype.focusNextMonth = function (event, focus = true) {
1110
+ event.preventDefault();
1111
+ const date = new Date(this.currentDate);
1112
+ date.setMonth(date.getMonth() + 1, 1);
1113
+ this.goToDate(date, focus);
1114
+ };
1115
+
1116
+ Datepicker.prototype.focusPreviousMonth = function (event, focus = true) {
1117
+ event.preventDefault();
1118
+ const date = new Date(this.currentDate);
1119
+ date.setMonth(date.getMonth() - 1, 1);
1120
+ this.goToDate(date, focus);
1121
+ };
1122
+
1123
+ // year navigation
1124
+ Datepicker.prototype.focusNextYear = function (event, focus = true) {
1125
+ event.preventDefault();
1126
+ const date = new Date(this.currentDate);
1127
+ date.setFullYear(date.getFullYear() + 1, date.getMonth(), 1);
1128
+ this.goToDate(date, focus);
1129
+ };
1130
+
1131
+ Datepicker.prototype.focusPreviousYear = function (event, focus = true) {
1132
+ event.preventDefault();
1133
+ const date = new Date(this.currentDate);
1134
+ date.setFullYear(date.getFullYear() - 1, date.getMonth(), 1);
1135
+ this.goToDate(date, focus);
1136
+ };
1137
+
1138
+ /**
1139
+ * Parse dataset
1140
+ *
1141
+ * Loop over an object and normalise each value using {@link normaliseString},
1142
+ * optionally expanding nested `i18n.field`
1143
+ *
1144
+ * @param {{ schema: Schema }} Component - Component class
1145
+ * @param {DOMStringMap} dataset - HTML element dataset
1146
+ * @returns {Object} Normalised dataset
1147
+ */
1148
+ Datepicker.prototype.parseDataset = function (schema, dataset) {
1149
+ const parsed = {};
1150
+
1151
+ for (const [field, attributes] of Object.entries(schema.properties)) {
1152
+ if (field in dataset) {
1153
+ parsed[field] = dataset[field];
1154
+ }
1155
+ }
1156
+
1157
+ return parsed;
1158
+ };
1159
+
1160
+ /**
1161
+ * Config merging function
1162
+ *
1163
+ * Takes any number of objects and combines them together, with
1164
+ * greatest priority on the LAST item passed in.
1165
+ *
1166
+ * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
1167
+ * @returns {{ [key: string]: unknown }} A merged config object
1168
+ */
1169
+ Datepicker.prototype.mergeConfigs = function (...configObjects) {
1170
+ const formattedConfigObject = {};
1171
+
1172
+ // Loop through each of the passed objects
1173
+ for (const configObject of configObjects) {
1174
+ for (const key of Object.keys(configObject)) {
1175
+ const option = formattedConfigObject[key];
1176
+ const override = configObject[key];
1177
+
1178
+ // Push their keys one-by-one into formattedConfigObject. Any duplicate
1179
+ // keys with object values will be merged, otherwise the new value will
1180
+ // override the existing value.
1181
+ if (typeof option === "object" && typeof override === "object") {
1182
+ // @ts-expect-error Index signature for type 'string' is missing
1183
+ formattedConfigObject[key] = this.mergeConfigs(option, override);
1184
+ } else {
1185
+ formattedConfigObject[key] = override;
1186
+ }
1187
+ }
1188
+ }
1189
+
1190
+ return formattedConfigObject;
1191
+ };
1192
+
1193
+ /**
1194
+ *
1195
+ * @param {HTMLElement} button
1196
+ * @param {number} index
1197
+ * @param {number} row
1198
+ * @param {number} column
1199
+ * @param {Datepicker} picker
1200
+ * @constructor
1201
+ */
1202
+ function DSCalendarDay(button, index, row, column, picker) {
1203
+ this.index = index;
1204
+ this.row = row;
1205
+ this.column = column;
1206
+ this.button = button;
1207
+ this.picker = picker;
1208
+
1209
+ this.date = new Date();
1210
+ }
1211
+
1212
+ DSCalendarDay.prototype.init = function () {
1213
+ this.button.addEventListener("keydown", this.keyPress.bind(this));
1214
+ this.button.addEventListener("click", this.click.bind(this));
1215
+ };
1216
+
1217
+ /**
1218
+ * @param {Date} day - the Date for the calendar day
1219
+ * @param {boolean} hidden - visibility of the day
1220
+ * @param {boolean} disabled - is the day selectable or excluded
1221
+ */
1222
+ DSCalendarDay.prototype.update = function (day, hidden, disabled) {
1223
+ let label = day.getDate();
1224
+ let accessibleLabel = this.picker.formattedDateHuman(day);
1225
+
1226
+ if (disabled) {
1227
+ this.button.setAttribute("aria-disabled", true);
1228
+ accessibleLabel = "Excluded date, " + accessibleLabel;
1229
+ } else {
1230
+ this.button.removeAttribute("aria-disabled");
1231
+ }
1232
+
1233
+ if (hidden) {
1234
+ this.button.style.display = "none";
1235
+ } else {
1236
+ this.button.style.display = "block";
1237
+ }
1238
+
1239
+ this.button.innerHTML = `<span class="govuk-visually-hidden">${accessibleLabel}</span><span aria-hidden="true">${label}</span>`;
1240
+ this.date = new Date(day);
1241
+ };
1242
+
1243
+ DSCalendarDay.prototype.click = function (event) {
1244
+ this.picker.goToDate(this.date);
1245
+ this.picker.selectDate(this.date);
1246
+
1247
+ event.stopPropagation();
1248
+ event.preventDefault();
1249
+ };
1250
+
1251
+ DSCalendarDay.prototype.keyPress = function (event) {
1252
+ let calendarNavKey = true;
1253
+
1254
+ switch (event.key) {
1255
+ case "ArrowLeft":
1256
+ this.picker.focusPreviousDay();
1257
+ break;
1258
+ case "ArrowRight":
1259
+ this.picker.focusNextDay();
1260
+ break;
1261
+ case "ArrowUp":
1262
+ this.picker.focusPreviousWeek();
1263
+ break;
1264
+ case "ArrowDown":
1265
+ this.picker.focusNextWeek();
1266
+ break;
1267
+ case "Home":
1268
+ this.picker.focusFirstDayOfWeek();
1269
+ break;
1270
+ case "End":
1271
+ this.picker.focusLastDayOfWeek();
1272
+ break;
1273
+ case "PageUp":
1274
+ // eslint-disable-next-line no-unused-expressions
1275
+ event.shiftKey
1276
+ ? this.picker.focusPreviousYear(event)
1277
+ : this.picker.focusPreviousMonth(event);
1278
+ break;
1279
+ case "PageDown":
1280
+ // eslint-disable-next-line no-unused-expressions
1281
+ event.shiftKey
1282
+ ? this.picker.focusNextYear(event)
1283
+ : this.picker.focusNextMonth(event);
1284
+ break;
1285
+ default:
1286
+ calendarNavKey = false;
1287
+ break;
1288
+ }
1289
+
1290
+ if (calendarNavKey) {
1291
+ event.preventDefault();
1292
+ event.stopPropagation();
1293
+ }
1294
+ };
1295
+
1296
+ MOJFrontend.DatePicker = Datepicker;
1297
+
1298
+ /**
1299
+ * Schema for component config
1300
+ *
1301
+ * @typedef {object} Schema
1302
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
1303
+ */
1304
+
1305
+ /**
1306
+ * Schema property for component config
1307
+ *
1308
+ * @typedef {object} SchemaProperty
1309
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
1310
+ */
1311
+
373
1312
  MOJFrontend.FilterToggleButton = function(options) {
374
1313
  this.options = options;
375
1314
  this.container = $(this.options.toggleButton.container);