@ministryofjustice/frontend 5.0.0 → 5.1.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 (112) hide show
  1. package/moj/all.bundle.js +1549 -1062
  2. package/moj/all.bundle.js.map +1 -1
  3. package/moj/all.bundle.mjs +1845 -1054
  4. package/moj/all.bundle.mjs.map +1 -1
  5. package/moj/all.mjs +7 -90
  6. package/moj/all.mjs.map +1 -1
  7. package/moj/all.scss +1 -0
  8. package/moj/all.scss.map +1 -1
  9. package/moj/common/index.mjs +57 -0
  10. package/moj/common/index.mjs.map +1 -0
  11. package/moj/common/moj-frontend-version.mjs +14 -0
  12. package/moj/common/moj-frontend-version.mjs.map +1 -0
  13. package/moj/components/add-another/add-another.bundle.js +105 -76
  14. package/moj/components/add-another/add-another.bundle.js.map +1 -1
  15. package/moj/components/add-another/add-another.bundle.mjs +222 -71
  16. package/moj/components/add-another/add-another.bundle.mjs.map +1 -1
  17. package/moj/components/add-another/add-another.mjs +103 -72
  18. package/moj/components/add-another/add-another.mjs.map +1 -1
  19. package/moj/components/alert/alert.bundle.js +115 -191
  20. package/moj/components/alert/alert.bundle.js.map +1 -1
  21. package/moj/components/alert/alert.bundle.mjs +354 -186
  22. package/moj/components/alert/alert.bundle.mjs.map +1 -1
  23. package/moj/components/alert/alert.mjs +55 -140
  24. package/moj/components/alert/alert.mjs.map +1 -1
  25. package/moj/components/button-menu/README.md +3 -1
  26. package/moj/components/button-menu/button-menu.bundle.js +91 -120
  27. package/moj/components/button-menu/button-menu.bundle.js.map +1 -1
  28. package/moj/components/button-menu/button-menu.bundle.mjs +329 -114
  29. package/moj/components/button-menu/button-menu.bundle.mjs.map +1 -1
  30. package/moj/components/button-menu/button-menu.mjs +89 -116
  31. package/moj/components/button-menu/button-menu.mjs.map +1 -1
  32. package/moj/components/date-picker/date-picker.bundle.js +174 -154
  33. package/moj/components/date-picker/date-picker.bundle.js.map +1 -1
  34. package/moj/components/date-picker/date-picker.bundle.mjs +411 -147
  35. package/moj/components/date-picker/date-picker.bundle.mjs.map +1 -1
  36. package/moj/components/date-picker/date-picker.mjs +172 -150
  37. package/moj/components/date-picker/date-picker.mjs.map +1 -1
  38. package/moj/components/filter/template.njk +1 -1
  39. package/moj/components/filter-toggle-button/filter-toggle-button.bundle.js +133 -44
  40. package/moj/components/filter-toggle-button/filter-toggle-button.bundle.js.map +1 -1
  41. package/moj/components/filter-toggle-button/filter-toggle-button.bundle.mjs +374 -41
  42. package/moj/components/filter-toggle-button/filter-toggle-button.bundle.mjs.map +1 -1
  43. package/moj/components/filter-toggle-button/filter-toggle-button.mjs +131 -40
  44. package/moj/components/filter-toggle-button/filter-toggle-button.mjs.map +1 -1
  45. package/moj/components/form-validator/form-validator.bundle.js +159 -69
  46. package/moj/components/form-validator/form-validator.bundle.js.map +1 -1
  47. package/moj/components/form-validator/form-validator.bundle.mjs +399 -65
  48. package/moj/components/form-validator/form-validator.bundle.mjs.map +1 -1
  49. package/moj/components/form-validator/form-validator.mjs +134 -54
  50. package/moj/components/form-validator/form-validator.mjs.map +1 -1
  51. package/moj/components/multi-file-upload/multi-file-upload.bundle.js +291 -117
  52. package/moj/components/multi-file-upload/multi-file-upload.bundle.js.map +1 -1
  53. package/moj/components/multi-file-upload/multi-file-upload.bundle.mjs +527 -109
  54. package/moj/components/multi-file-upload/multi-file-upload.bundle.mjs.map +1 -1
  55. package/moj/components/multi-file-upload/multi-file-upload.mjs +288 -101
  56. package/moj/components/multi-file-upload/multi-file-upload.mjs.map +1 -1
  57. package/moj/components/multi-file-upload/template.njk +1 -1
  58. package/moj/components/multi-select/multi-select.bundle.js +106 -41
  59. package/moj/components/multi-select/multi-select.bundle.js.map +1 -1
  60. package/moj/components/multi-select/multi-select.bundle.mjs +346 -37
  61. package/moj/components/multi-select/multi-select.bundle.mjs.map +1 -1
  62. package/moj/components/multi-select/multi-select.mjs +104 -37
  63. package/moj/components/multi-select/multi-select.mjs.map +1 -1
  64. package/moj/components/password-reveal/_password-reveal.scss +3 -1
  65. package/moj/components/password-reveal/_password-reveal.scss.map +1 -1
  66. package/moj/components/password-reveal/password-reveal.bundle.js +32 -29
  67. package/moj/components/password-reveal/password-reveal.bundle.js.map +1 -1
  68. package/moj/components/password-reveal/password-reveal.bundle.mjs +149 -24
  69. package/moj/components/password-reveal/password-reveal.bundle.mjs.map +1 -1
  70. package/moj/components/password-reveal/password-reveal.mjs +30 -25
  71. package/moj/components/password-reveal/password-reveal.mjs.map +1 -1
  72. package/moj/components/rich-text-editor/README.md +4 -3
  73. package/moj/components/rich-text-editor/rich-text-editor.bundle.js +127 -62
  74. package/moj/components/rich-text-editor/rich-text-editor.bundle.js.map +1 -1
  75. package/moj/components/rich-text-editor/rich-text-editor.bundle.mjs +367 -58
  76. package/moj/components/rich-text-editor/rich-text-editor.bundle.mjs.map +1 -1
  77. package/moj/components/rich-text-editor/rich-text-editor.mjs +125 -58
  78. package/moj/components/rich-text-editor/rich-text-editor.mjs.map +1 -1
  79. package/moj/components/search-toggle/search-toggle.bundle.js +94 -26
  80. package/moj/components/search-toggle/search-toggle.bundle.js.map +1 -1
  81. package/moj/components/search-toggle/search-toggle.bundle.mjs +334 -22
  82. package/moj/components/search-toggle/search-toggle.bundle.mjs.map +1 -1
  83. package/moj/components/search-toggle/search-toggle.mjs +92 -22
  84. package/moj/components/search-toggle/search-toggle.mjs.map +1 -1
  85. package/moj/components/sortable-table/sortable-table.bundle.js +151 -83
  86. package/moj/components/sortable-table/sortable-table.bundle.js.map +1 -1
  87. package/moj/components/sortable-table/sortable-table.bundle.mjs +390 -78
  88. package/moj/components/sortable-table/sortable-table.bundle.mjs.map +1 -1
  89. package/moj/components/sortable-table/sortable-table.mjs +149 -79
  90. package/moj/components/sortable-table/sortable-table.mjs.map +1 -1
  91. package/moj/core/_all.scss +3 -0
  92. package/moj/core/_all.scss.map +1 -0
  93. package/moj/core/_moj-frontend-properties.scss +7 -0
  94. package/moj/core/_moj-frontend-properties.scss.map +1 -0
  95. package/moj/filters/prototype-kit-13-filters.js +4 -3
  96. package/moj/helpers.bundle.js +22 -77
  97. package/moj/helpers.bundle.js.map +1 -1
  98. package/moj/helpers.bundle.mjs +23 -74
  99. package/moj/helpers.bundle.mjs.map +1 -1
  100. package/moj/helpers.mjs +23 -74
  101. package/moj/helpers.mjs.map +1 -1
  102. package/moj/moj-frontend.min.css +1 -1
  103. package/moj/moj-frontend.min.css.map +1 -1
  104. package/moj/moj-frontend.min.js +1 -1
  105. package/moj/moj-frontend.min.js.map +1 -1
  106. package/package.json +1 -1
  107. package/moj/version.bundle.js +0 -12
  108. package/moj/version.bundle.js.map +0 -1
  109. package/moj/version.bundle.mjs +0 -4
  110. package/moj/version.bundle.mjs.map +0 -1
  111. package/moj/version.mjs +0 -4
  112. package/moj/version.mjs.map +0 -1
package/moj/all.bundle.js CHANGED
@@ -1,164 +1,259 @@
1
1
  (function (global, factory) {
2
- typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
- typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
- (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.MOJFrontend = global.MOJFrontend || {}));
5
- })(this, (function (exports) { 'use strict';
6
-
7
- class AddAnother {
8
- constructor(container) {
9
- this.container = container;
10
- if (this.container.hasAttribute('data-moj-add-another-init')) {
11
- return this;
12
- }
13
- this.container.setAttribute('data-moj-add-another-init', '');
14
- this.container.addEventListener('click', this.onRemoveButtonClick.bind(this));
15
- this.container.addEventListener('click', this.onAddButtonClick.bind(this));
16
- const buttons = this.container.querySelectorAll('.moj-add-another__add-button, moj-add-another__remove-button');
17
- buttons.forEach(button => {
18
- if (!(button instanceof HTMLButtonElement)) {
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('govuk-frontend')) :
3
+ typeof define === 'function' && define.amd ? define(['exports', 'govuk-frontend'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.MOJFrontend = global.MOJFrontend || {}, global.GOVUKFrontend));
5
+ })(this, (function (exports, govukFrontend) { 'use strict';
6
+
7
+ /*
8
+ * This variable is automatically overwritten during builds and releases.
9
+ * It doesn't need to be updated manually.
10
+ */
11
+
12
+ /**
13
+ * MoJ Frontend release version
14
+ *
15
+ * {@link https://github.com/ministryofjustice/moj-frontend/releases}
16
+ */
17
+ const version = '5.1.0';
18
+
19
+ class AddAnother extends govukFrontend.Component {
20
+ /**
21
+ * @param {Element | null} $root - HTML element to use for add another
22
+ */
23
+ constructor($root) {
24
+ super($root);
25
+ this.$root.addEventListener('click', this.onRemoveButtonClick.bind(this));
26
+ this.$root.addEventListener('click', this.onAddButtonClick.bind(this));
27
+ const $buttons = this.$root.querySelectorAll('.moj-add-another__add-button, moj-add-another__remove-button');
28
+ $buttons.forEach($button => {
29
+ if (!($button instanceof HTMLButtonElement)) {
19
30
  return;
20
31
  }
21
- button.type = 'button';
32
+ $button.type = 'button';
22
33
  });
23
34
  }
35
+
36
+ /**
37
+ * @param {MouseEvent} event - Click event
38
+ */
24
39
  onAddButtonClick(event) {
25
- const button = event.target;
26
- if (!button || !(button instanceof HTMLButtonElement) || !button.classList.contains('moj-add-another__add-button')) {
40
+ const $button = event.target;
41
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-add-another__add-button')) {
27
42
  return;
28
43
  }
29
- const items = this.getItems();
30
- const item = this.getNewItem();
31
- if (!item || !(item instanceof HTMLElement)) {
44
+ const $items = this.getItems();
45
+ const $item = this.getNewItem();
46
+ if (!$item || !($item instanceof HTMLElement)) {
32
47
  return;
33
48
  }
34
- this.updateAttributes(item, items.length);
35
- this.resetItem(item);
36
- const firstItem = items[0];
37
- if (!this.hasRemoveButton(firstItem)) {
38
- this.createRemoveButton(firstItem);
49
+ this.updateAttributes($item, $items.length);
50
+ this.resetItem($item);
51
+ const $firstItem = $items[0];
52
+ if (!this.hasRemoveButton($firstItem)) {
53
+ this.createRemoveButton($firstItem);
39
54
  }
40
- items[items.length - 1].after(item);
41
- const input = item.querySelector('input, textarea, select');
42
- if (input && input instanceof HTMLInputElement) {
43
- input.focus();
55
+ $items[$items.length - 1].after($item);
56
+ const $input = $item.querySelector('input, textarea, select');
57
+ if ($input && $input instanceof HTMLInputElement) {
58
+ $input.focus();
44
59
  }
45
60
  }
46
- hasRemoveButton(item) {
47
- return item.querySelectorAll('.moj-add-another__remove-button').length;
61
+
62
+ /**
63
+ * @param {HTMLElement} $item - Add another item
64
+ */
65
+ hasRemoveButton($item) {
66
+ return $item.querySelectorAll('.moj-add-another__remove-button').length;
48
67
  }
49
68
  getItems() {
50
- if (!this.container) {
69
+ if (!this.$root) {
51
70
  return [];
52
71
  }
53
- const items = Array.from(this.container.querySelectorAll('.moj-add-another__item'));
54
- return items.filter(item => item instanceof HTMLElement);
72
+ const $items = Array.from(this.$root.querySelectorAll('.moj-add-another__item'));
73
+ return $items.filter(item => item instanceof HTMLElement);
55
74
  }
56
75
  getNewItem() {
57
- const items = this.getItems();
58
- const item = items[0].cloneNode(true);
59
- if (!item || !(item instanceof HTMLElement)) {
76
+ const $items = this.getItems();
77
+ const $item = $items[0].cloneNode(true);
78
+ if (!$item || !($item instanceof HTMLElement)) {
60
79
  return;
61
80
  }
62
- if (!this.hasRemoveButton(item)) {
63
- this.createRemoveButton(item);
81
+ if (!this.hasRemoveButton($item)) {
82
+ this.createRemoveButton($item);
64
83
  }
65
- return item;
84
+ return $item;
66
85
  }
67
- updateAttributes(item, index) {
68
- item.querySelectorAll('[data-name]').forEach(el => {
69
- if (!(el instanceof HTMLInputElement)) {
86
+
87
+ /**
88
+ * @param {HTMLElement} $item - Add another item
89
+ * @param {number} index - Add another item index
90
+ */
91
+ updateAttributes($item, index) {
92
+ $item.querySelectorAll('[data-name]').forEach($input => {
93
+ if (!($input instanceof HTMLInputElement)) {
70
94
  return;
71
95
  }
72
- const name = el.getAttribute('data-name') || '';
73
- const id = el.getAttribute('data-id') || '';
74
- const originalId = el.id;
75
- el.name = name.replace(/%index%/, `${index}`);
76
- el.id = id.replace(/%index%/, `${index}`);
77
- const label = el.parentElement.querySelector('label') || el.closest('label') || item.querySelector(`[for="${originalId}"]`);
78
- if (label && label instanceof HTMLLabelElement) {
79
- label.htmlFor = el.id;
96
+ const name = $input.getAttribute('data-name') || '';
97
+ const id = $input.getAttribute('data-id') || '';
98
+ const originalId = $input.id;
99
+ $input.name = name.replace(/%index%/, `${index}`);
100
+ $input.id = id.replace(/%index%/, `${index}`);
101
+ const $label = $input.parentElement.querySelector('label') || $input.closest('label') || $item.querySelector(`[for="${originalId}"]`);
102
+ if ($label && $label instanceof HTMLLabelElement) {
103
+ $label.htmlFor = $input.id;
80
104
  }
81
105
  });
82
106
  }
83
- createRemoveButton(item) {
84
- const button = document.createElement('button');
85
- button.type = 'button';
86
- button.classList.add('govuk-button', 'govuk-button--secondary', 'moj-add-another__remove-button');
87
- button.textContent = 'Remove';
88
- item.append(button);
107
+
108
+ /**
109
+ * @param {HTMLElement} $item - Add another item
110
+ */
111
+ createRemoveButton($item) {
112
+ const $button = document.createElement('button');
113
+ $button.type = 'button';
114
+ $button.classList.add('govuk-button', 'govuk-button--secondary', 'moj-add-another__remove-button');
115
+ $button.textContent = 'Remove';
116
+ $item.append($button);
89
117
  }
90
- resetItem(item) {
91
- item.querySelectorAll('[data-name], [data-id]').forEach(el => {
92
- if (!(el instanceof HTMLInputElement)) {
118
+
119
+ /**
120
+ * @param {HTMLElement} $item - Add another item
121
+ */
122
+ resetItem($item) {
123
+ $item.querySelectorAll('[data-name], [data-id]').forEach($input => {
124
+ if (!($input instanceof HTMLInputElement)) {
93
125
  return;
94
126
  }
95
- if (el.type === 'checkbox' || el.type === 'radio') {
96
- el.checked = false;
127
+ if ($input.type === 'checkbox' || $input.type === 'radio') {
128
+ $input.checked = false;
97
129
  } else {
98
- el.value = '';
130
+ $input.value = '';
99
131
  }
100
132
  });
101
133
  }
134
+
135
+ /**
136
+ * @param {MouseEvent} event - Click event
137
+ */
102
138
  onRemoveButtonClick(event) {
103
- const button = event.target;
104
- if (!button || !(button instanceof HTMLButtonElement) || !button.classList.contains('moj-add-another__remove-button')) {
139
+ const $button = event.target;
140
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-add-another__remove-button')) {
105
141
  return;
106
142
  }
107
- button.closest('.moj-add-another__item').remove();
108
- const items = this.getItems();
109
- if (items.length === 1) {
110
- items[0].querySelector('.moj-add-another__remove-button').remove();
143
+ $button.closest('.moj-add-another__item').remove();
144
+ const $items = this.getItems();
145
+ if ($items.length === 1) {
146
+ $items[0].querySelector('.moj-add-another__remove-button').remove();
111
147
  }
112
- items.forEach((el, index) => {
113
- this.updateAttributes(el, index);
148
+ $items.forEach(($item, index) => {
149
+ this.updateAttributes($item, index);
114
150
  });
115
151
  this.focusHeading();
116
152
  }
117
153
  focusHeading() {
118
- const heading = this.container.querySelector('.moj-add-another__heading');
119
- if (heading && heading instanceof HTMLElement) {
120
- heading.focus();
154
+ const $heading = this.$root.querySelector('.moj-add-another__heading');
155
+ if ($heading && $heading instanceof HTMLElement) {
156
+ $heading.focus();
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Name for the component used when initialising using data-module attributes.
162
+ */
163
+ }
164
+ AddAnother.moduleName = 'moj-add-another';
165
+
166
+ /**
167
+ * GOV.UK Frontend helpers
168
+ *
169
+ * @todo Import from GOV.UK Frontend
170
+ */
171
+
172
+ /**
173
+ * Move focus to element
174
+ *
175
+ * Sets tabindex to -1 to make the element programmatically focusable,
176
+ * but removes it on blur as the element doesn't need to be focused again.
177
+ *
178
+ * @template {HTMLElement} FocusElement
179
+ * @param {FocusElement} $element - HTML element
180
+ * @param {object} [options] - Handler options
181
+ * @param {function(this: FocusElement): void} [options.onBeforeFocus] - Callback before focus
182
+ * @param {function(this: FocusElement): void} [options.onBlur] - Callback on blur
183
+ */
184
+ function setFocus($element, options = {}) {
185
+ var _options$onBeforeFocu;
186
+ const isFocusable = $element.getAttribute('tabindex');
187
+ if (!isFocusable) {
188
+ $element.setAttribute('tabindex', '-1');
189
+ }
190
+
191
+ /**
192
+ * Handle element focus
193
+ */
194
+ function onFocus() {
195
+ $element.addEventListener('blur', onBlur, {
196
+ once: true
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Handle element blur
202
+ */
203
+ function onBlur() {
204
+ var _options$onBlur;
205
+ (_options$onBlur = options.onBlur) == null || _options$onBlur.call($element);
206
+ if (!isFocusable) {
207
+ $element.removeAttribute('tabindex');
121
208
  }
122
209
  }
210
+
211
+ // Add listener to reset element on blur, after focus
212
+ $element.addEventListener('focus', onFocus, {
213
+ once: true
214
+ });
215
+
216
+ // Focus element
217
+ (_options$onBeforeFocu = options.onBeforeFocus) == null || _options$onBeforeFocu.call($element);
218
+ $element.focus();
123
219
  }
124
220
 
125
- function removeAttributeValue(el, attr, value) {
221
+ /**
222
+ * @param {Element} $element - Element to remove attribute value from
223
+ * @param {string} attr - Attribute name
224
+ * @param {string} value - Attribute value
225
+ */
226
+ function removeAttributeValue($element, attr, value) {
126
227
  let re, m;
127
- if (el.getAttribute(attr)) {
128
- if (el.getAttribute(attr) === value) {
129
- el.removeAttribute(attr);
228
+ if ($element.getAttribute(attr)) {
229
+ if ($element.getAttribute(attr) === value) {
230
+ $element.removeAttribute(attr);
130
231
  } else {
131
232
  re = new RegExp(`(^|\\s)${value}(\\s|$)`);
132
- m = el.getAttribute(attr).match(re);
233
+ m = $element.getAttribute(attr).match(re);
133
234
  if (m && m.length === 3) {
134
- el.setAttribute(attr, el.getAttribute(attr).replace(re, m[1] && m[2] ? ' ' : ''));
235
+ $element.setAttribute(attr, $element.getAttribute(attr).replace(re, m[1] && m[2] ? ' ' : ''));
135
236
  }
136
237
  }
137
238
  }
138
239
  }
139
- function addAttributeValue(el, attr, value) {
240
+
241
+ /**
242
+ * @param {Element} $element - Element to add attribute value to
243
+ * @param {string} attr - Attribute name
244
+ * @param {string} value - Attribute value
245
+ */
246
+ function addAttributeValue($element, attr, value) {
140
247
  let re;
141
- if (!el.getAttribute(attr)) {
142
- el.setAttribute(attr, value);
248
+ if (!$element.getAttribute(attr)) {
249
+ $element.setAttribute(attr, value);
143
250
  } else {
144
251
  re = new RegExp(`(^|\\s)${value}(\\s|$)`);
145
- if (!re.test(el.getAttribute(attr))) {
146
- el.setAttribute(attr, `${el.getAttribute(attr)} ${value}`);
252
+ if (!re.test($element.getAttribute(attr))) {
253
+ $element.setAttribute(attr, `${$element.getAttribute(attr)} ${value}`);
147
254
  }
148
255
  }
149
256
  }
150
- function dragAndDropSupported() {
151
- const div = document.createElement('div');
152
- return typeof div.ondrop !== 'undefined';
153
- }
154
- function formDataSupported() {
155
- return typeof FormData === 'function';
156
- }
157
- function fileApiSupported() {
158
- const input = document.createElement('input');
159
- input.type = 'file';
160
- return typeof input.files !== 'undefined';
161
- }
162
257
 
163
258
  /**
164
259
  * Find an elements preceding sibling
@@ -219,89 +314,15 @@
219
314
  }
220
315
 
221
316
  /**
222
- * Move focus to element
223
- *
224
- * Sets tabindex to -1 to make the element programmatically focusable,
225
- * but removes it on blur as the element doesn't need to be focused again.
226
- *
227
- * @param {HTMLElement} $element - HTML element
228
- * @param {object} [options] - Handler options
229
- * @param {function(this: HTMLElement): void} [options.onBeforeFocus] - Callback before focus
230
- * @param {function(this: HTMLElement): void} [options.onBlur] - Callback on blur
317
+ * @augments {ConfigurableComponent<AlertConfig>}
231
318
  */
232
- function setFocus($element, options = {}) {
233
- const isFocusable = $element.getAttribute('tabindex');
234
- if (!isFocusable) {
235
- $element.setAttribute('tabindex', '-1');
236
- }
237
-
319
+ class Alert extends govukFrontend.ConfigurableComponent {
238
320
  /**
239
- * Handle element focus
240
- */
241
- function onFocus() {
242
- $element.addEventListener('blur', onBlur, {
243
- once: true
244
- });
245
- }
246
-
247
- /**
248
- * Handle element blur
249
- */
250
- function onBlur() {
251
- if (options.onBlur) {
252
- options.onBlur.call($element);
253
- }
254
- if (!isFocusable) {
255
- $element.removeAttribute('tabindex');
256
- }
257
- }
258
-
259
- // Add listener to reset element on blur, after focus
260
- $element.addEventListener('focus', onFocus, {
261
- once: true
262
- });
263
-
264
- // Focus element
265
- if (options.onBeforeFocus) {
266
- options.onBeforeFocus.call($element);
267
- }
268
- $element.focus();
269
- }
270
-
271
- class Alert {
272
- /**
273
- * @param {Element | null} $module - HTML element to use for alert
321
+ * @param {Element | null} $root - HTML element to use for alert
274
322
  * @param {AlertConfig} [config] - Alert config
275
323
  */
276
- constructor($module, config = {}) {
277
- if (!$module || !($module instanceof HTMLElement)) {
278
- return this;
279
- }
280
- const schema = Object.freeze({
281
- properties: {
282
- dismissible: {
283
- type: 'boolean'
284
- },
285
- dismissText: {
286
- type: 'string'
287
- },
288
- disableAutoFocus: {
289
- type: 'boolean'
290
- },
291
- focusOnDismissSelector: {
292
- type: 'string'
293
- }
294
- }
295
- });
296
- const defaults = {
297
- dismissible: false,
298
- dismissText: 'Dismiss',
299
- disableAutoFocus: false
300
- };
301
-
302
- // data attributes override JS config, which overrides defaults
303
- this.config = this.mergeConfigs(defaults, config, this.parseDataset(schema, $module.dataset));
304
- this.$module = $module;
324
+ constructor($root, config = {}) {
325
+ super($root, config);
305
326
 
306
327
  /**
307
328
  * Focus the alert
@@ -314,14 +335,14 @@
314
335
  * do this based on user research findings, or to avoid a clash with another
315
336
  * element which should be focused when the page loads.
316
337
  */
317
- if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
318
- setFocus(this.$module);
338
+ if (this.$root.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
339
+ setFocus(this.$root);
319
340
  }
320
- this.$dismissButton = this.$module.querySelector('.moj-alert__dismiss');
341
+ this.$dismissButton = this.$root.querySelector('.moj-alert__dismiss');
321
342
  if (this.config.dismissible && this.$dismissButton) {
322
343
  this.$dismissButton.innerHTML = this.config.dismissText;
323
344
  this.$dismissButton.removeAttribute('hidden');
324
- this.$module.addEventListener('click', event => {
345
+ this.$root.addEventListener('click', event => {
325
346
  if (event.target instanceof Node && this.$dismissButton.contains(event.target)) {
326
347
  this.dimiss();
327
348
  }
@@ -342,7 +363,7 @@
342
363
 
343
364
  // Is the next sibling another alert
344
365
  if (!$elementToRecieveFocus) {
345
- const $nextSibling = this.$module.nextElementSibling;
366
+ const $nextSibling = this.$root.nextElementSibling;
346
367
  if ($nextSibling && $nextSibling.matches('.moj-alert')) {
347
368
  $elementToRecieveFocus = $nextSibling;
348
369
  }
@@ -350,13 +371,13 @@
350
371
 
351
372
  // Else try to find any preceding sibling alert or heading
352
373
  if (!$elementToRecieveFocus) {
353
- $elementToRecieveFocus = getPreviousSibling(this.$module, '.moj-alert, h1, h2, h3, h4, h5, h6');
374
+ $elementToRecieveFocus = getPreviousSibling(this.$root, '.moj-alert, h1, h2, h3, h4, h5, h6');
354
375
  }
355
376
 
356
377
  // Else find the closest ancestor heading, or fallback to main, or last resort
357
378
  // use the body element
358
379
  if (!$elementToRecieveFocus) {
359
- $elementToRecieveFocus = findNearestMatchingElement(this.$module, 'h1, h2, h3, h4, h5, h6, main, body');
380
+ $elementToRecieveFocus = findNearestMatchingElement(this.$root, 'h1, h2, h3, h4, h5, h6, main, body');
360
381
  }
361
382
 
362
383
  // If we have an element, place focus on it
@@ -365,111 +386,12 @@
365
386
  }
366
387
 
367
388
  // Remove the alert
368
- this.$module.remove();
369
- }
370
-
371
- /**
372
- * Normalise string
373
- *
374
- * 'If it looks like a duck, and it quacks like a duck…' 🦆
375
- *
376
- * If the passed value looks like a boolean or a number, convert it to a boolean
377
- * or number.
378
- *
379
- * Designed to be used to convert config passed via data attributes (which are
380
- * always strings) into something sensible.
381
- *
382
- * @internal
383
- * @param {DOMStringMap[string]} value - The value to normalise
384
- * @param {SchemaProperty} [property] - Component schema property
385
- * @returns {string | boolean | number | undefined} Normalised data
386
- */
387
- normaliseString(value, property) {
388
- const trimmedValue = value ? value.trim() : '';
389
- let output;
390
- let outputType;
391
- if (property && property.type) {
392
- outputType = property.type;
393
- }
394
-
395
- // No schema type set? Determine automatically
396
- if (!outputType) {
397
- if (['true', 'false'].includes(trimmedValue)) {
398
- outputType = 'boolean';
399
- }
400
-
401
- // Empty / whitespace-only strings are considered finite so we need to check
402
- // the length of the trimmed string as well
403
- if (trimmedValue.length > 0 && Number.isFinite(Number(trimmedValue))) {
404
- outputType = 'number';
405
- }
406
- }
407
- switch (outputType) {
408
- case 'boolean':
409
- output = trimmedValue === 'true';
410
- break;
411
- case 'number':
412
- output = Number(trimmedValue);
413
- break;
414
- default:
415
- output = value;
416
- }
417
- return output;
418
- }
419
-
420
- /**
421
- * Parse dataset
422
- *
423
- * Loop over an object and normalise each value using {@link normaliseString},
424
- * optionally expanding nested `i18n.field`
425
- *
426
- * @param {Schema} schema - component schema
427
- * @param {DOMStringMap} dataset - HTML element dataset
428
- * @returns {object} Normalised dataset
429
- */
430
- parseDataset(schema, dataset) {
431
- const parsed = {};
432
- for (const [field, property] of Object.entries(schema.properties)) {
433
- if (field in dataset) {
434
- if (dataset[field]) {
435
- parsed[field] = this.normaliseString(dataset[field], property);
436
- }
437
- }
438
- }
439
- return parsed;
389
+ this.$root.remove();
440
390
  }
441
391
 
442
392
  /**
443
- * Config merging function
444
- *
445
- * Takes any number of objects and combines them together, with
446
- * greatest priority on the LAST item passed in.
447
- *
448
- * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
449
- * @returns {{ [key: string]: unknown }} A merged config object
450
- */
451
- mergeConfigs(...configObjects) {
452
- const formattedConfigObject = {};
453
-
454
- // Loop through each of the passed objects
455
- for (const configObject of configObjects) {
456
- for (const key of Object.keys(configObject)) {
457
- const option = formattedConfigObject[key];
458
- const override = configObject[key];
459
-
460
- // Push their keys one-by-one into formattedConfigObject. Any duplicate
461
- // keys with object values will be merged, otherwise the new value will
462
- // override the existing value.
463
- if (typeof option === 'object' && typeof override === 'object') {
464
- // @ts-expect-error Index signature for type 'string' is missing
465
- formattedConfigObject[key] = this.mergeConfigs(option, override);
466
- } else {
467
- formattedConfigObject[key] = override;
468
- }
469
- }
470
- }
471
- return formattedConfigObject;
472
- }
393
+ * Name for the component used when initialising using data-module attributes.
394
+ */
473
395
  }
474
396
 
475
397
  /**
@@ -480,71 +402,87 @@
480
402
  * @property {string} [focusOnDismissSelector] - CSS Selector for element to be focused on dismiss
481
403
  */
482
404
 
483
- class ButtonMenu {
405
+ /**
406
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
407
+ */
408
+ Alert.moduleName = 'moj-alert';
409
+ /**
410
+ * Alert default config
411
+ *
412
+ * @type {AlertConfig}
413
+ */
414
+ Alert.defaults = Object.freeze({
415
+ dismissible: false,
416
+ dismissText: 'Dismiss',
417
+ disableAutoFocus: false
418
+ });
419
+ /**
420
+ * Alert config schema
421
+ *
422
+ * @satisfies {Schema<AlertConfig>}
423
+ */
424
+ Alert.schema = Object.freeze(/** @type {const} */{
425
+ properties: {
426
+ dismissible: {
427
+ type: 'boolean'
428
+ },
429
+ dismissText: {
430
+ type: 'string'
431
+ },
432
+ disableAutoFocus: {
433
+ type: 'boolean'
434
+ },
435
+ focusOnDismissSelector: {
436
+ type: 'string'
437
+ }
438
+ }
439
+ });
440
+
441
+ /**
442
+ * @augments {ConfigurableComponent<ButtonMenuConfig>}
443
+ */
444
+ class ButtonMenu extends govukFrontend.ConfigurableComponent {
484
445
  /**
485
- * @param {Element | null} $module - HTML element to use for button menu
446
+ * @param {Element | null} $root - HTML element to use for button menu
486
447
  * @param {ButtonMenuConfig} [config] - Button menu config
487
448
  */
488
- constructor($module, config = {}) {
489
- if (!$module || !($module instanceof HTMLElement)) {
490
- return this;
491
- }
492
- const schema = Object.freeze({
493
- properties: {
494
- buttonText: {
495
- type: 'string'
496
- },
497
- buttonClasses: {
498
- type: 'string'
499
- },
500
- alignMenu: {
501
- type: 'string'
502
- }
503
- }
504
- });
505
- const defaults = {
506
- buttonText: 'Actions',
507
- alignMenu: 'left',
508
- buttonClasses: ''
509
- };
510
- // data attributes override JS config, which overrides defaults
511
- this.config = this.mergeConfigs(defaults, config, this.parseDataset(schema, $module.dataset));
512
- this.$module = $module;
449
+ constructor($root, config = {}) {
450
+ super($root, config);
513
451
 
514
452
  // If only one button is provided, don't initiate a menu and toggle button
515
453
  // if classes have been provided for the toggleButton, apply them to the single item
516
- if (this.$module.children.length === 1) {
517
- const button = this.$module.children[0];
518
- button.classList.forEach(className => {
454
+ if (this.$root.children.length === 1) {
455
+ const $button = this.$root.children[0];
456
+ $button.classList.forEach(className => {
519
457
  if (className.startsWith('govuk-button-')) {
520
- button.classList.remove(className);
458
+ $button.classList.remove(className);
521
459
  }
522
- button.classList.remove('moj-button-menu__item');
523
- button.classList.add('moj-button-menu__single-button');
460
+ $button.classList.remove('moj-button-menu__item');
461
+ $button.classList.add('moj-button-menu__single-button');
524
462
  });
525
463
  if (this.config.buttonClasses) {
526
- button.classList.add(...this.config.buttonClasses.split(' '));
464
+ $button.classList.add(...this.config.buttonClasses.split(' '));
527
465
  }
528
466
  }
529
- // Otherwise intialise a button menu
530
- if (this.$module.children.length > 1) {
467
+ // Otherwise initialise a button menu
468
+ if (this.$root.children.length > 1) {
531
469
  this.initMenu();
532
470
  }
533
471
  }
534
472
  initMenu() {
535
473
  this.$menu = this.createMenu();
536
- this.$module.insertAdjacentHTML('afterbegin', this.toggleTemplate());
474
+ this.$root.insertAdjacentHTML('afterbegin', this.toggleTemplate());
537
475
  this.setupMenuItems();
538
- this.$menuToggle = this.$module.querySelector(':scope > button');
539
- this.items = this.$menu.querySelectorAll('a, button');
476
+ this.$menuToggle = this.$root.querySelector(':scope > button');
477
+ this.$items = this.$menu.querySelectorAll('a, button');
540
478
  this.$menuToggle.addEventListener('click', event => {
541
479
  this.toggleMenu(event);
542
480
  });
543
- this.$module.addEventListener('keydown', event => {
481
+ this.$root.addEventListener('keydown', event => {
544
482
  this.handleKeyDown(event);
545
483
  });
546
484
  document.addEventListener('click', event => {
547
- if (!this.$module.contains(event.target)) {
485
+ if (event.target instanceof Node && !this.$root.contains(event.target)) {
548
486
  this.closeMenu(false);
549
487
  }
550
488
  });
@@ -557,30 +495,30 @@
557
495
  if (this.config.alignMenu === 'right') {
558
496
  $menu.classList.add('moj-button-menu__wrapper--right');
559
497
  }
560
- this.$module.appendChild($menu);
561
- while (this.$module.firstChild !== $menu) {
562
- $menu.appendChild(this.$module.firstChild);
498
+ this.$root.appendChild($menu);
499
+ while (this.$root.firstChild !== $menu) {
500
+ $menu.appendChild(this.$root.firstChild);
563
501
  }
564
502
  return $menu;
565
503
  }
566
504
  setupMenuItems() {
567
- Array.from(this.$menu.children).forEach(item => {
505
+ Array.from(this.$menu.children).forEach($menuItem => {
568
506
  // wrap item in li tag
569
- const listItem = document.createElement('li');
570
- this.$menu.insertBefore(listItem, item);
571
- listItem.appendChild(item);
572
- item.setAttribute('tabindex', '-1');
573
- if (item.tagName === 'BUTTON') {
574
- item.setAttribute('type', 'button');
507
+ const $listItem = document.createElement('li');
508
+ this.$menu.insertBefore($listItem, $menuItem);
509
+ $listItem.appendChild($menuItem);
510
+ $menuItem.setAttribute('tabindex', '-1');
511
+ if ($menuItem.tagName === 'BUTTON') {
512
+ $menuItem.setAttribute('type', 'button');
575
513
  }
576
- item.classList.forEach(className => {
514
+ $menuItem.classList.forEach(className => {
577
515
  if (className.startsWith('govuk-button')) {
578
- item.classList.remove(className);
516
+ $menuItem.classList.remove(className);
579
517
  }
580
518
  });
581
519
 
582
520
  // add a slight delay after click before closing the menu, makes it *feel* better
583
- item.addEventListener('click', () => {
521
+ $menuItem.addEventListener('click', () => {
584
522
  setTimeout(() => {
585
523
  this.closeMenu(false);
586
524
  }, 50);
@@ -605,6 +543,10 @@
605
543
  isOpen() {
606
544
  return this.$menuToggle.getAttribute('aria-expanded') === 'true';
607
545
  }
546
+
547
+ /**
548
+ * @param {MouseEvent} event - Click event
549
+ */
608
550
  toggleMenu(event) {
609
551
  event.preventDefault();
610
552
 
@@ -650,18 +592,22 @@
650
592
  * @param {number} index - the index of the item to focus
651
593
  */
652
594
  focusItem(index) {
653
- if (index >= this.items.length) index = 0;
654
- if (index < 0) index = this.items.length - 1;
655
- const menuItem = this.items.item(index);
656
- if (menuItem) {
657
- menuItem.focus();
595
+ if (index >= this.$items.length) index = 0;
596
+ if (index < 0) index = this.$items.length - 1;
597
+ const $menuItem = this.$items.item(index);
598
+ if ($menuItem) {
599
+ $menuItem.focus();
658
600
  }
659
601
  }
660
602
  currentFocusIndex() {
661
- const activeElement = document.activeElement;
662
- const menuItems = Array.from(this.items);
663
- return menuItems.indexOf(activeElement);
603
+ const $activeElement = document.activeElement;
604
+ const $menuItems = Array.from(this.$items);
605
+ return ($activeElement instanceof HTMLAnchorElement || $activeElement instanceof HTMLButtonElement) && $menuItems.indexOf($activeElement);
664
606
  }
607
+
608
+ /**
609
+ * @param {KeyboardEvent} event - Keydown event
610
+ */
665
611
  handleKeyDown(event) {
666
612
  if (event.target === this.$menuToggle) {
667
613
  switch (event.key) {
@@ -671,11 +617,11 @@
671
617
  break;
672
618
  case 'ArrowUp':
673
619
  event.preventDefault();
674
- this.openMenu(this.items.length - 1);
620
+ this.openMenu(this.$items.length - 1);
675
621
  break;
676
622
  }
677
623
  }
678
- if (this.$menu.contains(event.target) && this.isOpen()) {
624
+ if (event.target instanceof Node && this.$menu.contains(event.target) && this.isOpen()) {
679
625
  switch (event.key) {
680
626
  case 'ArrowDown':
681
627
  event.preventDefault();
@@ -695,7 +641,7 @@
695
641
  break;
696
642
  case 'End':
697
643
  event.preventDefault();
698
- this.focusItem(this.items.length - 1);
644
+ this.focusItem(this.$items.length - 1);
699
645
  break;
700
646
  }
701
647
  }
@@ -708,58 +654,8 @@
708
654
  }
709
655
 
710
656
  /**
711
- * Parse dataset
712
- *
713
- * Loop over an object and normalise each value using {@link normaliseString},
714
- * optionally expanding nested `i18n.field`
715
- *
716
- * @param {Schema} schema - component schema
717
- * @param {DOMStringMap} dataset - HTML element dataset
718
- * @returns {object} Normalised dataset
719
- */
720
- parseDataset(schema, dataset) {
721
- const parsed = {};
722
- for (const [field,,] of Object.entries(schema.properties)) {
723
- if (field in dataset) {
724
- if (dataset[field]) {
725
- parsed[field] = dataset[field];
726
- }
727
- }
728
- }
729
- return parsed;
730
- }
731
-
732
- /**
733
- * Config merging function
734
- *
735
- * Takes any number of objects and combines them together, with
736
- * greatest priority on the LAST item passed in.
737
- *
738
- * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
739
- * @returns {{ [key: string]: unknown }} A merged config object
740
- */
741
- mergeConfigs(...configObjects) {
742
- const formattedConfigObject = {};
743
-
744
- // Loop through each of the passed objects
745
- for (const configObject of configObjects) {
746
- for (const key of Object.keys(configObject)) {
747
- const option = formattedConfigObject[key];
748
- const override = configObject[key];
749
-
750
- // Push their keys one-by-one into formattedConfigObject. Any duplicate
751
- // keys with object values will be merged, otherwise the new value will
752
- // override the existing value.
753
- if (typeof option === 'object' && typeof override === 'object') {
754
- // @ts-expect-error Index signature for type 'string' is missing
755
- formattedConfigObject[key] = this.mergeConfigs(option, override);
756
- } else {
757
- formattedConfigObject[key] = override;
758
- }
759
- }
760
- }
761
- return formattedConfigObject;
762
- }
657
+ * Name for the component used when initialising using data-module attributes.
658
+ */
763
659
  }
764
660
 
765
661
  /**
@@ -769,69 +665,68 @@
769
665
  * @property {string} [buttonClasses='govuk-button--secondary'] - css classes applied to the toggle button
770
666
  */
771
667
 
772
- class DatePicker {
668
+ /**
669
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
670
+ */
671
+ ButtonMenu.moduleName = 'moj-button-menu';
672
+ /**
673
+ * Button menu config
674
+ *
675
+ * @type {ButtonMenuConfig}
676
+ */
677
+ ButtonMenu.defaults = Object.freeze({
678
+ buttonText: 'Actions',
679
+ alignMenu: 'left',
680
+ buttonClasses: ''
681
+ });
682
+ /**
683
+ * Button menu config schema
684
+ *
685
+ * @type {Schema<ButtonMenuConfig>}
686
+ */
687
+ ButtonMenu.schema = Object.freeze(/** @type {const} */{
688
+ properties: {
689
+ buttonText: {
690
+ type: 'string'
691
+ },
692
+ buttonClasses: {
693
+ type: 'string'
694
+ },
695
+ alignMenu: {
696
+ type: 'string'
697
+ }
698
+ }
699
+ });
700
+
701
+ /**
702
+ * @augments {ConfigurableComponent<DatePickerConfig>}
703
+ */
704
+ class DatePicker extends govukFrontend.ConfigurableComponent {
773
705
  /**
774
- * @param {Element | null} $module - HTML element to use for date picker
706
+ * @param {Element | null} $root - HTML element to use for date picker
775
707
  * @param {DatePickerConfig} [config] - Date picker config
776
708
  */
777
- constructor($module, config = {}) {
778
- if (!$module || !($module instanceof HTMLElement)) {
779
- return this;
780
- }
781
- const $input = $module.querySelector('.moj-js-datepicker-input');
782
-
783
- // Check that required elements are present
709
+ constructor($root, config = {}) {
710
+ var _this$config$input$el;
711
+ super($root, config);
712
+ const $input = (_this$config$input$el = this.config.input.element) != null ? _this$config$input$el : this.$root.querySelector(this.config.input.selector);
784
713
  if (!$input || !($input instanceof HTMLInputElement)) {
785
714
  return this;
786
715
  }
787
- this.$module = $module;
788
716
  this.$input = $input;
789
- const schema = Object.freeze({
790
- properties: {
791
- excludedDates: {
792
- type: 'string'
793
- },
794
- excludedDays: {
795
- type: 'string'
796
- },
797
- leadingZeros: {
798
- type: 'string'
799
- },
800
- maxDate: {
801
- type: 'string'
802
- },
803
- minDate: {
804
- type: 'string'
805
- },
806
- weekStartDay: {
807
- type: 'string'
808
- }
809
- }
810
- });
811
- const defaults = {
812
- leadingZeros: false,
813
- weekStartDay: 'monday'
814
- };
815
-
816
- // data attributes override JS config, which overrides defaults
817
- this.config = this.mergeConfigs(defaults, config, this.parseDataset(schema, $module.dataset));
818
717
  this.dayLabels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
819
718
  this.monthLabels = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
820
719
  this.currentDate = new Date();
821
720
  this.currentDate.setHours(0, 0, 0, 0);
822
- this.calendarDays = [];
823
- this.excludedDates = [];
824
- this.excludedDays = [];
721
+ this.calendarDays = /** @type {DSCalendarDay[]} */[];
722
+ this.excludedDates = /** @type {Date[]} */[];
723
+ this.excludedDays = /** @type {number[]} */[];
825
724
  this.buttonClass = 'moj-datepicker__button';
826
725
  this.selectedDayButtonClass = 'moj-datepicker__button--selected';
827
726
  this.currentDayButtonClass = 'moj-datepicker__button--current';
828
727
  this.todayButtonClass = 'moj-datepicker__button--today';
829
- if (this.$module.dataset.initialized) {
830
- return this;
831
- }
832
728
  this.setOptions();
833
729
  this.initControls();
834
- this.$module.setAttribute('data-initialized', 'true');
835
730
  }
836
731
  initControls() {
837
732
  this.id = `datepicker-${this.$input.id}`;
@@ -846,15 +741,23 @@
846
741
  $inputWrapper.appendChild(this.$input);
847
742
  $inputWrapper.insertAdjacentHTML('beforeend', this.toggleTemplate());
848
743
  $componentWrapper.insertAdjacentElement('beforeend', this.$dialog);
849
- this.$calendarButton = this.$module.querySelector('.moj-js-datepicker-toggle');
850
- this.$dialogTitle = this.$dialog.querySelector('.moj-js-datepicker-month-year');
744
+ this.$calendarButton = /** @type {HTMLButtonElement} */
745
+ this.$root.querySelector('.moj-js-datepicker-toggle');
746
+ this.$dialogTitle = /** @type {HTMLHeadingElement} */
747
+ this.$dialog.querySelector('.moj-js-datepicker-month-year');
851
748
  this.createCalendar();
852
- this.$prevMonthButton = this.$dialog.querySelector('.moj-js-datepicker-prev-month');
853
- this.$prevYearButton = this.$dialog.querySelector('.moj-js-datepicker-prev-year');
854
- this.$nextMonthButton = this.$dialog.querySelector('.moj-js-datepicker-next-month');
855
- this.$nextYearButton = this.$dialog.querySelector('.moj-js-datepicker-next-year');
856
- this.$cancelButton = this.$dialog.querySelector('.moj-js-datepicker-cancel');
857
- this.$okButton = this.$dialog.querySelector('.moj-js-datepicker-ok');
749
+ this.$prevMonthButton = /** @type {HTMLButtonElement} */
750
+ this.$dialog.querySelector('.moj-js-datepicker-prev-month');
751
+ this.$prevYearButton = /** @type {HTMLButtonElement} */
752
+ this.$dialog.querySelector('.moj-js-datepicker-prev-year');
753
+ this.$nextMonthButton = /** @type {HTMLButtonElement} */
754
+ this.$dialog.querySelector('.moj-js-datepicker-next-month');
755
+ this.$nextYearButton = /** @type {HTMLButtonElement} */
756
+ this.$dialog.querySelector('.moj-js-datepicker-next-year');
757
+ this.$cancelButton = /** @type {HTMLButtonElement} */
758
+ this.$dialog.querySelector('.moj-js-datepicker-cancel');
759
+ this.$okButton = /** @type {HTMLButtonElement} */
760
+ this.$dialog.querySelector('.moj-js-datepicker-ok');
858
761
 
859
762
  // add event listeners
860
763
  this.$prevMonthButton.addEventListener('click', event => this.focusPreviousMonth(event, false));
@@ -868,9 +771,9 @@
868
771
  this.$okButton.addEventListener('click', () => {
869
772
  this.selectDate(this.currentDate);
870
773
  });
871
- const dialogButtons = this.$dialog.querySelectorAll('button:not([disabled="true"])');
872
- this.$firstButtonInDialog = dialogButtons[0];
873
- this.$lastButtonInDialog = dialogButtons[dialogButtons.length - 1];
774
+ const $dialogButtons = this.$dialog.querySelectorAll('button:not([disabled="true"])');
775
+ this.$firstButtonInDialog = $dialogButtons[0];
776
+ this.$lastButtonInDialog = $dialogButtons[$dialogButtons.length - 1];
874
777
  this.$firstButtonInDialog.addEventListener('keydown', event => this.firstButtonKeydown(event));
875
778
  this.$lastButtonInDialog.addEventListener('keydown', event => this.lastButtonKeydown(event));
876
779
  this.$calendarButton.addEventListener('click', event => this.toggleDialog(event));
@@ -1016,7 +919,6 @@
1016
919
  this.setMinAndMaxDatesOnCalendar();
1017
920
  this.setExcludedDates();
1018
921
  this.setExcludedDays();
1019
- this.setLeadingZeros();
1020
922
  this.setWeekStartDay();
1021
923
  }
1022
924
  setMinAndMaxDatesOnCalendar() {
@@ -1041,10 +943,10 @@
1041
943
  }
1042
944
  }
1043
945
 
1044
- /*
946
+ /**
1045
947
  * Parses a daterange string into an array of dates
1046
- * @param {String} datestring - A daterange string in the format "dd/mm/yyyy-dd/mm/yyyy"
1047
- * @returns {Date[]}
948
+ *
949
+ * @param {string} datestring - A daterange string in the format "dd/mm/yyyy-dd/mm/yyyy"
1048
950
  */
1049
951
  parseDateRangeString(datestring) {
1050
952
  const dates = [];
@@ -1071,17 +973,6 @@
1071
973
  this.excludedDays = this.config.excludedDays.replace(/\s+/, ' ').toLowerCase().split(' ').map(item => weekDays.indexOf(item)).filter(item => item !== -1);
1072
974
  }
1073
975
  }
1074
- setLeadingZeros() {
1075
- if (typeof this.config.leadingZeros !== 'boolean') {
1076
- if (this.config.leadingZeros.toLowerCase() === 'true') {
1077
- this.config.leadingZeros = true;
1078
- return;
1079
- }
1080
- if (this.config.leadingZeros.toLowerCase() === 'false') {
1081
- this.config.leadingZeros = false;
1082
- }
1083
- }
1084
- }
1085
976
  setWeekStartDay() {
1086
977
  const weekStartDayParam = this.config.weekStartDay;
1087
978
  if (weekStartDayParam && weekStartDayParam.toLowerCase() === 'sunday') {
@@ -1094,7 +985,7 @@
1094
985
  }
1095
986
 
1096
987
  /**
1097
- * Determine if a date is selecteable
988
+ * Determine if a date is selectable
1098
989
  *
1099
990
  * @param {Date} date - the date to check
1100
991
  * @returns {boolean}
@@ -1160,24 +1051,36 @@
1160
1051
  /**
1161
1052
  * Get a human readable date in the format Monday 2 March 2024
1162
1053
  *
1163
- * @param {Date} date - date to format
1054
+ * @param {Date} date - Date to format
1164
1055
  * @returns {string}
1165
1056
  */
1166
1057
  formattedDateHuman(date) {
1167
1058
  return `${this.dayLabels[(date.getDay() + 6) % 7]} ${date.getDate()} ${this.monthLabels[date.getMonth()]} ${date.getFullYear()}`;
1168
1059
  }
1060
+
1061
+ /**
1062
+ * @param {MouseEvent} event - Click event
1063
+ */
1169
1064
  backgroundClick(event) {
1170
1065
  if (this.isOpen() && event.target instanceof Node && !this.$dialog.contains(event.target) && !this.$input.contains(event.target) && !this.$calendarButton.contains(event.target)) {
1171
1066
  event.preventDefault();
1172
1067
  this.closeDialog();
1173
1068
  }
1174
1069
  }
1070
+
1071
+ /**
1072
+ * @param {KeyboardEvent} event - Keydown event
1073
+ */
1175
1074
  firstButtonKeydown(event) {
1176
1075
  if (event.key === 'Tab' && event.shiftKey) {
1177
1076
  this.$lastButtonInDialog.focus();
1178
1077
  event.preventDefault();
1179
1078
  }
1180
1079
  }
1080
+
1081
+ /**
1082
+ * @param {KeyboardEvent} event - Keydown event
1083
+ */
1181
1084
  lastButtonKeydown(event) {
1182
1085
  if (event.key === 'Tab' && !event.shiftKey) {
1183
1086
  this.$firstButtonInDialog.focus();
@@ -1207,49 +1110,57 @@
1207
1110
  thisDay.setDate(thisDay.getDate() + 1);
1208
1111
  }
1209
1112
  }
1113
+
1114
+ /**
1115
+ * @param {boolean} [focus] - Focus the day button
1116
+ */
1210
1117
  setCurrentDate(focus = true) {
1211
1118
  const {
1212
1119
  currentDate
1213
1120
  } = this;
1214
1121
  this.calendarDays.forEach(calendarDay => {
1215
- calendarDay.button.classList.add('moj-datepicker__button');
1216
- calendarDay.button.classList.add('moj-datepicker__calendar-day');
1217
- calendarDay.button.setAttribute('tabindex', '-1');
1218
- calendarDay.button.classList.remove(this.selectedDayButtonClass);
1122
+ calendarDay.$button.classList.add('moj-datepicker__button');
1123
+ calendarDay.$button.classList.add('moj-datepicker__calendar-day');
1124
+ calendarDay.$button.setAttribute('tabindex', '-1');
1125
+ calendarDay.$button.classList.remove(this.selectedDayButtonClass);
1219
1126
  const calendarDayDate = calendarDay.date;
1220
1127
  calendarDayDate.setHours(0, 0, 0, 0);
1221
1128
  const today = new Date();
1222
1129
  today.setHours(0, 0, 0, 0);
1223
1130
  if (calendarDayDate.getTime() === currentDate.getTime() /* && !calendarDay.button.disabled */) {
1224
1131
  if (focus) {
1225
- calendarDay.button.setAttribute('tabindex', '0');
1226
- calendarDay.button.focus();
1227
- calendarDay.button.classList.add(this.selectedDayButtonClass);
1132
+ calendarDay.$button.setAttribute('tabindex', '0');
1133
+ calendarDay.$button.focus();
1134
+ calendarDay.$button.classList.add(this.selectedDayButtonClass);
1228
1135
  }
1229
1136
  }
1230
1137
  if (this.inputDate && calendarDayDate.getTime() === this.inputDate.getTime()) {
1231
- calendarDay.button.classList.add(this.currentDayButtonClass);
1232
- calendarDay.button.setAttribute('aria-current', 'date');
1138
+ calendarDay.$button.classList.add(this.currentDayButtonClass);
1139
+ calendarDay.$button.setAttribute('aria-current', 'date');
1233
1140
  } else {
1234
- calendarDay.button.classList.remove(this.currentDayButtonClass);
1235
- calendarDay.button.removeAttribute('aria-current');
1141
+ calendarDay.$button.classList.remove(this.currentDayButtonClass);
1142
+ calendarDay.$button.removeAttribute('aria-current');
1236
1143
  }
1237
1144
  if (calendarDayDate.getTime() === today.getTime()) {
1238
- calendarDay.button.classList.add(this.todayButtonClass);
1145
+ calendarDay.$button.classList.add(this.todayButtonClass);
1239
1146
  } else {
1240
- calendarDay.button.classList.remove(this.todayButtonClass);
1147
+ calendarDay.$button.classList.remove(this.todayButtonClass);
1241
1148
  }
1242
1149
  });
1243
1150
 
1244
1151
  // if no date is tab-able, make the first non-disabled date tab-able
1245
1152
  if (!focus) {
1246
1153
  const enabledDays = this.calendarDays.filter(calendarDay => {
1247
- return window.getComputedStyle(calendarDay.button).display === 'block' && !calendarDay.button.disabled;
1154
+ return window.getComputedStyle(calendarDay.$button).display === 'block' && !calendarDay.$button.disabled;
1248
1155
  });
1249
- enabledDays[0].button.setAttribute('tabindex', '0');
1156
+ enabledDays[0].$button.setAttribute('tabindex', '0');
1250
1157
  this.currentDate = enabledDays[0].date;
1251
1158
  }
1252
1159
  }
1160
+
1161
+ /**
1162
+ * @param {Date} date - Date to select
1163
+ */
1253
1164
  selectDate(date) {
1254
1165
  if (this.isExcludedDate(date)) {
1255
1166
  return;
@@ -1266,6 +1177,10 @@
1266
1177
  isOpen() {
1267
1178
  return this.$dialog.classList.contains('moj-datepicker__dialog--open');
1268
1179
  }
1180
+
1181
+ /**
1182
+ * @param {MouseEvent} event - Click event
1183
+ */
1269
1184
  toggleDialog(event) {
1270
1185
  event.preventDefault();
1271
1186
  if (this.isOpen()) {
@@ -1300,6 +1215,11 @@
1300
1215
  this.$calendarButton.setAttribute('aria-expanded', 'false');
1301
1216
  this.$calendarButton.focus();
1302
1217
  }
1218
+
1219
+ /**
1220
+ * @param {Date} date - Date to go to
1221
+ * @param {boolean} [focus] - Focus the day button
1222
+ */
1303
1223
  goToDate(date, focus) {
1304
1224
  const current = this.currentDate;
1305
1225
  this.currentDate = date;
@@ -1351,27 +1271,47 @@
1351
1271
  this.goToDate(date);
1352
1272
  }
1353
1273
 
1354
- // month navigation
1274
+ /**
1275
+ * Month navigation
1276
+ *
1277
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1278
+ * @param {boolean} [focus] - Focus the day button
1279
+ */
1355
1280
  focusNextMonth(event, focus = true) {
1356
1281
  event.preventDefault();
1357
1282
  const date = new Date(this.currentDate);
1358
1283
  date.setMonth(date.getMonth() + 1, 1);
1359
1284
  this.goToDate(date, focus);
1360
1285
  }
1361
- focusPreviousMonth(event, focus = true) {
1362
- event.preventDefault();
1286
+
1287
+ /**
1288
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1289
+ * @param {boolean} [focus] - Focus the day button
1290
+ */
1291
+ focusPreviousMonth(event, focus = true) {
1292
+ event.preventDefault();
1363
1293
  const date = new Date(this.currentDate);
1364
1294
  date.setMonth(date.getMonth() - 1, 1);
1365
1295
  this.goToDate(date, focus);
1366
1296
  }
1367
1297
 
1368
- // year navigation
1298
+ /**
1299
+ * Year navigation
1300
+ *
1301
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1302
+ * @param {boolean} [focus] - Focus the day button
1303
+ */
1369
1304
  focusNextYear(event, focus = true) {
1370
1305
  event.preventDefault();
1371
1306
  const date = new Date(this.currentDate);
1372
1307
  date.setFullYear(date.getFullYear() + 1, date.getMonth(), 1);
1373
1308
  this.goToDate(date, focus);
1374
1309
  }
1310
+
1311
+ /**
1312
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1313
+ * @param {boolean} [focus] - Focus the day button
1314
+ */
1375
1315
  focusPreviousYear(event, focus = true) {
1376
1316
  event.preventDefault();
1377
1317
  const date = new Date(this.currentDate);
@@ -1380,72 +1320,70 @@
1380
1320
  }
1381
1321
 
1382
1322
  /**
1383
- * Parse dataset
1384
- *
1385
- * @param {Schema} schema - Component class
1386
- * @param {DOMStringMap} dataset - HTML element dataset
1387
- * @returns {object} Normalised dataset
1388
- */
1389
- parseDataset(schema, dataset) {
1390
- const parsed = {};
1391
- for (const [field,,] of Object.entries(schema.properties)) {
1392
- if (field in dataset) {
1393
- parsed[field] = dataset[field];
1394
- }
1395
- }
1396
- return parsed;
1397
- }
1398
-
1399
- /**
1400
- * Config merging function
1401
- *
1402
- * Takes any number of objects and combines them together, with
1403
- * greatest priority on the LAST item passed in.
1404
- *
1405
- * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
1406
- * @returns {{ [key: string]: unknown }} A merged config object
1407
- */
1408
- mergeConfigs(...configObjects) {
1409
- const formattedConfigObject = {};
1410
-
1411
- // Loop through each of the passed objects
1412
- for (const configObject of configObjects) {
1413
- for (const key of Object.keys(configObject)) {
1414
- const option = formattedConfigObject[key];
1415
- const override = configObject[key];
1416
-
1417
- // Push their keys one-by-one into formattedConfigObject. Any duplicate
1418
- // keys with object values will be merged, otherwise the new value will
1419
- // override the existing value.
1420
- if (typeof option === 'object' && typeof override === 'object') {
1421
- // @ts-expect-error Index signature for type 'string' is missing
1422
- formattedConfigObject[key] = this.mergeConfigs(option, override);
1423
- } else {
1424
- formattedConfigObject[key] = override;
1425
- }
1426
- }
1427
- }
1428
- return formattedConfigObject;
1429
- }
1323
+ * Name for the component used when initialising using data-module attributes.
1324
+ */
1430
1325
  }
1326
+ DatePicker.moduleName = 'moj-date-picker';
1327
+ /**
1328
+ * Date picker default config
1329
+ *
1330
+ * @type {DatePickerConfig}
1331
+ */
1332
+ DatePicker.defaults = Object.freeze({
1333
+ leadingZeros: false,
1334
+ weekStartDay: 'monday',
1335
+ input: {
1336
+ selector: '.moj-js-datepicker-input'
1337
+ }
1338
+ });
1339
+ /**
1340
+ * Date picker config schema
1341
+ *
1342
+ * @satisfies {Schema<DatePickerConfig>}
1343
+ */
1344
+ DatePicker.schema = Object.freeze(/** @type {const} */{
1345
+ properties: {
1346
+ excludedDates: {
1347
+ type: 'string'
1348
+ },
1349
+ excludedDays: {
1350
+ type: 'string'
1351
+ },
1352
+ leadingZeros: {
1353
+ type: 'boolean'
1354
+ },
1355
+ maxDate: {
1356
+ type: 'string'
1357
+ },
1358
+ minDate: {
1359
+ type: 'string'
1360
+ },
1361
+ weekStartDay: {
1362
+ type: 'string'
1363
+ },
1364
+ input: {
1365
+ type: 'object'
1366
+ }
1367
+ }
1368
+ });
1431
1369
  class DSCalendarDay {
1432
1370
  /**
1433
1371
  *
1434
- * @param {HTMLElement} button
1372
+ * @param {HTMLButtonElement} $button
1435
1373
  * @param {number} index
1436
1374
  * @param {number} row
1437
1375
  * @param {number} column
1438
1376
  * @param {DatePicker} picker
1439
1377
  */
1440
- constructor(button, index, row, column, picker) {
1378
+ constructor($button, index, row, column, picker) {
1441
1379
  this.index = index;
1442
1380
  this.row = row;
1443
1381
  this.column = column;
1444
- this.button = button;
1382
+ this.$button = $button;
1445
1383
  this.picker = picker;
1446
1384
  this.date = new Date();
1447
- this.button.addEventListener('keydown', this.keyPress.bind(this));
1448
- this.button.addEventListener('click', this.click.bind(this));
1385
+ this.$button.addEventListener('keydown', this.keyPress.bind(this));
1386
+ this.$button.addEventListener('click', this.click.bind(this));
1449
1387
  }
1450
1388
 
1451
1389
  /**
@@ -1457,26 +1395,34 @@
1457
1395
  const label = day.getDate();
1458
1396
  let accessibleLabel = this.picker.formattedDateHuman(day);
1459
1397
  if (disabled) {
1460
- this.button.setAttribute('aria-disabled', 'true');
1398
+ this.$button.setAttribute('aria-disabled', 'true');
1461
1399
  accessibleLabel = `Excluded date, ${accessibleLabel}`;
1462
1400
  } else {
1463
- this.button.removeAttribute('aria-disabled');
1401
+ this.$button.removeAttribute('aria-disabled');
1464
1402
  }
1465
1403
  if (hidden) {
1466
- this.button.style.display = 'none';
1404
+ this.$button.style.display = 'none';
1467
1405
  } else {
1468
- this.button.style.display = 'block';
1406
+ this.$button.style.display = 'block';
1469
1407
  }
1470
- this.button.setAttribute('data-testid', this.picker.formattedDateFromDate(day));
1471
- this.button.innerHTML = `<span class="govuk-visually-hidden">${accessibleLabel}</span><span aria-hidden="true">${label}</span>`;
1408
+ this.$button.setAttribute('data-testid', this.picker.formattedDateFromDate(day));
1409
+ this.$button.innerHTML = `<span class="govuk-visually-hidden">${accessibleLabel}</span><span aria-hidden="true">${label}</span>`;
1472
1410
  this.date = new Date(day);
1473
1411
  }
1412
+
1413
+ /**
1414
+ * @param {MouseEvent} event - Click event
1415
+ */
1474
1416
  click(event) {
1475
1417
  this.picker.goToDate(this.date);
1476
1418
  this.picker.selectDate(this.date);
1477
1419
  event.stopPropagation();
1478
1420
  event.preventDefault();
1479
1421
  }
1422
+
1423
+ /**
1424
+ * @param {KeyboardEvent} event - Keydown event
1425
+ */
1480
1426
  keyPress(event) {
1481
1427
  let calendarNavKey = true;
1482
1428
  switch (event.key) {
@@ -1533,45 +1479,61 @@
1533
1479
  * @typedef {object} DatePickerConfig
1534
1480
  * @property {string} [excludedDates] - Dates that cannot be selected
1535
1481
  * @property {string} [excludedDays] - Days that cannot be selected
1536
- * @property {boolean} [leadingZeroes] - Whether to add leading zeroes when populating the field
1482
+ * @property {boolean} [leadingZeros] - Whether to add leading zeroes when populating the field
1537
1483
  * @property {string} [minDate] - The earliest available date
1538
1484
  * @property {string} [maxDate] - The latest available date
1539
1485
  * @property {string} [weekStartDay] - First day of the week in calendar view
1486
+ * @property {object} [input] - Input config
1487
+ * @property {string} [input.selector] - Selector for the input element
1488
+ * @property {Element | null} [input.element] - HTML element for the input
1540
1489
  */
1541
1490
 
1542
1491
  /**
1543
- * @import { Schema } from '../../all.mjs'
1492
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
1544
1493
  */
1545
1494
 
1546
- class FilterToggleButton {
1547
- constructor(options) {
1548
- this.options = options;
1549
- this.container = this.options.toggleButton.container;
1550
- this.filterContainer = this.options.filter.container;
1495
+ /**
1496
+ * @augments {ConfigurableComponent<FilterToggleButtonConfig>}
1497
+ */
1498
+ class FilterToggleButton extends govukFrontend.ConfigurableComponent {
1499
+ /**
1500
+ * @param {Element | null} $root - HTML element to use for filter toggle button
1501
+ * @param {FilterToggleButtonConfig} [config] - Filter toggle button config
1502
+ */
1503
+ constructor($root, config = {}) {
1504
+ var _this$config$toggleBu, _this$config$closeBut;
1505
+ super($root, config);
1506
+ const $toggleButtonContainer = (_this$config$toggleBu = this.config.toggleButtonContainer.element) != null ? _this$config$toggleBu : document.querySelector(this.config.toggleButtonContainer.selector);
1507
+ const $closeButtonContainer = (_this$config$closeBut = this.config.closeButtonContainer.element) != null ? _this$config$closeBut : this.$root.querySelector(this.config.closeButtonContainer.selector);
1508
+ if (!($toggleButtonContainer instanceof HTMLElement && $closeButtonContainer instanceof HTMLElement)) {
1509
+ return this;
1510
+ }
1511
+ this.$toggleButtonContainer = $toggleButtonContainer;
1512
+ this.$closeButtonContainer = $closeButtonContainer;
1551
1513
  this.createToggleButton();
1552
1514
  this.setupResponsiveChecks();
1553
- this.filterContainer.setAttribute('tabindex', '-1');
1554
- if (this.options.startHidden) {
1515
+ this.$root.setAttribute('tabindex', '-1');
1516
+ if (this.config.startHidden) {
1555
1517
  this.hideMenu();
1556
1518
  }
1557
1519
  }
1558
1520
  setupResponsiveChecks() {
1559
- this.mq = window.matchMedia(this.options.bigModeMediaQuery);
1521
+ this.mq = window.matchMedia(this.config.bigModeMediaQuery);
1560
1522
  this.mq.addListener(this.checkMode.bind(this));
1561
- this.checkMode(this.mq);
1523
+ this.checkMode();
1562
1524
  }
1563
1525
  createToggleButton() {
1564
- this.menuButton = document.createElement('button');
1565
- this.menuButton.setAttribute('type', 'button');
1566
- this.menuButton.setAttribute('aria-haspopup', 'true');
1567
- this.menuButton.setAttribute('aria-expanded', 'false');
1568
- this.menuButton.className = `govuk-button ${this.options.toggleButton.classes}`;
1569
- this.menuButton.textContent = this.options.toggleButton.showText;
1570
- this.menuButton.addEventListener('click', this.onMenuButtonClick.bind(this));
1571
- this.container.append(this.menuButton);
1572
- }
1573
- checkMode(mq) {
1574
- if (mq.matches) {
1526
+ this.$menuButton = document.createElement('button');
1527
+ this.$menuButton.setAttribute('type', 'button');
1528
+ this.$menuButton.setAttribute('aria-haspopup', 'true');
1529
+ this.$menuButton.setAttribute('aria-expanded', 'false');
1530
+ this.$menuButton.className = `govuk-button ${this.config.toggleButton.classes}`;
1531
+ this.$menuButton.textContent = this.config.toggleButton.showText;
1532
+ this.$menuButton.addEventListener('click', this.onMenuButtonClick.bind(this));
1533
+ this.$toggleButtonContainer.append(this.$menuButton);
1534
+ }
1535
+ checkMode() {
1536
+ if (this.mq.matches) {
1575
1537
  this.enableBigMode();
1576
1538
  } else {
1577
1539
  this.enableSmallMode();
@@ -1586,69 +1548,147 @@
1586
1548
  this.addCloseButton();
1587
1549
  }
1588
1550
  addCloseButton() {
1589
- if (!this.options.closeButton) {
1590
- return;
1591
- }
1592
- this.closeButton = document.createElement('button');
1593
- this.closeButton.setAttribute('type', 'button');
1594
- this.closeButton.className = 'moj-filter__close';
1595
- this.closeButton.textContent = this.options.closeButton.text;
1596
- this.closeButton.addEventListener('click', this.onCloseClick.bind(this));
1597
- this.options.closeButton.container.append(this.closeButton);
1551
+ this.$closeButton = document.createElement('button');
1552
+ this.$closeButton.setAttribute('type', 'button');
1553
+ this.$closeButton.className = this.config.closeButton.classes;
1554
+ this.$closeButton.textContent = this.config.closeButton.text;
1555
+ this.$closeButton.addEventListener('click', this.onCloseClick.bind(this));
1556
+ this.$closeButtonContainer.append(this.$closeButton);
1598
1557
  }
1599
1558
  onCloseClick() {
1600
1559
  this.hideMenu();
1601
- this.menuButton.focus();
1560
+ this.$menuButton.focus();
1602
1561
  }
1603
1562
  removeCloseButton() {
1604
- if (this.closeButton) {
1605
- this.closeButton.remove();
1606
- this.closeButton = null;
1563
+ if (this.$closeButton) {
1564
+ this.$closeButton.remove();
1565
+ this.$closeButton = null;
1607
1566
  }
1608
1567
  }
1609
1568
  hideMenu() {
1610
- this.menuButton.setAttribute('aria-expanded', 'false');
1611
- this.filterContainer.classList.add('moj-js-hidden');
1612
- this.menuButton.textContent = this.options.toggleButton.showText;
1569
+ this.$menuButton.setAttribute('aria-expanded', 'false');
1570
+ this.$root.classList.add('moj-js-hidden');
1571
+ this.$menuButton.textContent = this.config.toggleButton.showText;
1613
1572
  }
1614
1573
  showMenu() {
1615
- this.menuButton.setAttribute('aria-expanded', 'true');
1616
- this.filterContainer.classList.remove('moj-js-hidden');
1617
- this.menuButton.textContent = this.options.toggleButton.hideText;
1574
+ this.$menuButton.setAttribute('aria-expanded', 'true');
1575
+ this.$root.classList.remove('moj-js-hidden');
1576
+ this.$menuButton.textContent = this.config.toggleButton.hideText;
1618
1577
  }
1619
1578
  onMenuButtonClick() {
1620
1579
  this.toggle();
1621
1580
  }
1622
1581
  toggle() {
1623
- if (this.menuButton.getAttribute('aria-expanded') === 'false') {
1582
+ if (this.$menuButton.getAttribute('aria-expanded') === 'false') {
1624
1583
  this.showMenu();
1625
- this.filterContainer.focus();
1584
+ this.$root.focus();
1626
1585
  } else {
1627
1586
  this.hideMenu();
1628
1587
  }
1629
1588
  }
1589
+
1590
+ /**
1591
+ * Name for the component used when initialising using data-module attributes.
1592
+ */
1630
1593
  }
1631
1594
 
1632
- class FormValidator {
1595
+ /**
1596
+ * @typedef {object} FilterToggleButtonConfig
1597
+ * @property {string} [bigModeMediaQuery] - Media query for big mode
1598
+ * @property {boolean} [startHidden] - Whether to start hidden
1599
+ * @property {object} [toggleButton] - Toggle button config
1600
+ * @property {string} [toggleButton.showText] - Text for show button
1601
+ * @property {string} [toggleButton.hideText] - Text for hide button
1602
+ * @property {string} [toggleButton.classes] - Classes for toggle button
1603
+ * @property {object} [toggleButtonContainer] - Toggle button container config
1604
+ * @property {string} [toggleButtonContainer.selector] - Selector for toggle button container
1605
+ * @property {Element | null} [toggleButtonContainer.element] - HTML element for toggle button container
1606
+ * @property {object} [closeButton] - Close button config
1607
+ * @property {string} [closeButton.text] - Text for close button
1608
+ * @property {string} [closeButton.classes] - Classes for close button
1609
+ * @property {object} [closeButtonContainer] - Close button container config
1610
+ * @property {string} [closeButtonContainer.selector] - Selector for close button container
1611
+ * @property {Element | null} [closeButtonContainer.element] - HTML element for close button container
1612
+ */
1613
+
1614
+ /**
1615
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
1616
+ */
1617
+ FilterToggleButton.moduleName = 'moj-filter';
1618
+ /**
1619
+ * Filter toggle button config
1620
+ *
1621
+ * @type {FilterToggleButtonConfig}
1622
+ */
1623
+ FilterToggleButton.defaults = Object.freeze({
1624
+ bigModeMediaQuery: '(min-width: 48.0625em)',
1625
+ startHidden: true,
1626
+ toggleButton: {
1627
+ showText: 'Show filter',
1628
+ hideText: 'Hide filter',
1629
+ classes: 'govuk-button--secondary'
1630
+ },
1631
+ toggleButtonContainer: {
1632
+ selector: '.moj-action-bar__filter'
1633
+ },
1634
+ closeButton: {
1635
+ text: 'Close',
1636
+ classes: 'moj-filter__close'
1637
+ },
1638
+ closeButtonContainer: {
1639
+ selector: '.moj-filter__header-action'
1640
+ }
1641
+ });
1642
+ /**
1643
+ * Filter toggle button config schema
1644
+ *
1645
+ * @satisfies {Schema<FilterToggleButtonConfig>}
1646
+ */
1647
+ FilterToggleButton.schema = Object.freeze(/** @type {const} */{
1648
+ properties: {
1649
+ bigModeMediaQuery: {
1650
+ type: 'string'
1651
+ },
1652
+ startHidden: {
1653
+ type: 'boolean'
1654
+ },
1655
+ toggleButton: {
1656
+ type: 'object'
1657
+ },
1658
+ toggleButtonContainer: {
1659
+ type: 'object'
1660
+ },
1661
+ closeButton: {
1662
+ type: 'object'
1663
+ },
1664
+ closeButtonContainer: {
1665
+ type: 'object'
1666
+ }
1667
+ }
1668
+ });
1669
+
1670
+ /**
1671
+ * @augments {ConfigurableComponent<FormValidatorConfig, HTMLFormElement>}
1672
+ */
1673
+ class FormValidator extends govukFrontend.ConfigurableComponent {
1633
1674
  /**
1634
- * @param {Element | null} form - HTML element to use for form validator
1635
- * @param {FormValidatorConfig} [config] - Button menu config
1675
+ * @param {Element | null} $root - HTML element to use for form validator
1676
+ * @param {FormValidatorConfig} [config] - Form validator config
1636
1677
  */
1637
- constructor(form, config = {}) {
1638
- if (!form || !(form instanceof HTMLFormElement)) {
1678
+ constructor($root, config = {}) {
1679
+ super($root, config);
1680
+ const $summary = this.config.summary.element || document.querySelector(this.config.summary.selector);
1681
+ if (!$summary || !($summary instanceof HTMLElement)) {
1639
1682
  return this;
1640
1683
  }
1641
- this.form = form;
1642
- this.errors = [];
1643
- this.validators = [];
1644
- this.form.addEventListener('submit', this.onSubmit.bind(this));
1645
- this.summary = config.summary || document.querySelector('.govuk-error-summary');
1684
+ this.$summary = $summary;
1685
+ this.errors = /** @type {ValidationError[]} */[];
1686
+ this.validators = /** @type {Validator[]} */[];
1646
1687
  this.originalTitle = document.title;
1688
+ this.$root.addEventListener('submit', this.onSubmit.bind(this));
1647
1689
  }
1648
- escapeHtml(string) {
1649
- return String(string).replace(/[&<>"'`=/]/g, function fromEntityMap(s) {
1650
- return FormValidator.entityMap[s];
1651
- });
1690
+ escapeHtml(string = '') {
1691
+ return String(string).replace(/[&<>"'`=/]/g, name => FormValidator.entityMap[name]);
1652
1692
  }
1653
1693
  resetTitle() {
1654
1694
  document.title = this.originalTitle;
@@ -1657,10 +1697,10 @@
1657
1697
  document.title = `${this.errors.length} errors - ${document.title}`;
1658
1698
  }
1659
1699
  showSummary() {
1660
- this.summary.innerHTML = this.getSummaryHtml();
1661
- this.summary.classList.remove('moj-hidden');
1662
- this.summary.setAttribute('aria-labelledby', 'errorSummary-heading');
1663
- this.summary.focus();
1700
+ this.$summary.innerHTML = this.getSummaryHtml();
1701
+ this.$summary.classList.remove('moj-hidden');
1702
+ this.$summary.setAttribute('aria-labelledby', 'errorSummary-heading');
1703
+ this.$summary.focus();
1664
1704
  }
1665
1705
  getSummaryHtml() {
1666
1706
  let html = '<h2 id="error-summary-title" class="govuk-error-summary__title">There is a problem</h2>';
@@ -1678,9 +1718,13 @@
1678
1718
  return html;
1679
1719
  }
1680
1720
  hideSummary() {
1681
- this.summary.classList.add('moj-hidden');
1682
- this.summary.removeAttribute('aria-labelledby');
1721
+ this.$summary.classList.add('moj-hidden');
1722
+ this.$summary.removeAttribute('aria-labelledby');
1683
1723
  }
1724
+
1725
+ /**
1726
+ * @param {SubmitEvent} event - Form submit event
1727
+ */
1684
1728
  onSubmit(event) {
1685
1729
  this.removeInlineErrors();
1686
1730
  this.hideSummary();
@@ -1697,25 +1741,29 @@
1697
1741
  this.showInlineError(error);
1698
1742
  }
1699
1743
  }
1744
+
1745
+ /**
1746
+ * @param {ValidationError} error
1747
+ */
1700
1748
  showInlineError(error) {
1701
- const errorSpan = document.createElement('span');
1702
- errorSpan.id = `${error.fieldName}-error`;
1703
- errorSpan.classList.add('govuk-error-message');
1704
- errorSpan.innerHTML = this.escapeHtml(error.message);
1705
- const control = document.querySelector(`#${error.fieldName}`);
1706
- const fieldset = control.closest('.govuk-fieldset');
1707
- const fieldContainer = (fieldset || control).closest('.govuk-form-group');
1708
- const label = fieldContainer.querySelector('label');
1709
- const legend = fieldContainer.querySelector('legend');
1710
- fieldContainer.classList.add('govuk-form-group--error');
1711
- if (fieldset && legend) {
1712
- legend.after(errorSpan);
1713
- fieldContainer.setAttribute('aria-invalid', 'true');
1714
- addAttributeValue(fieldset, 'aria-describedby', errorSpan.id);
1715
- } else if (label && control) {
1716
- label.after(errorSpan);
1717
- control.setAttribute('aria-invalid', 'true');
1718
- addAttributeValue(control, 'aria-describedby', errorSpan.id);
1749
+ const $errorSpan = document.createElement('span');
1750
+ $errorSpan.id = `${error.fieldName}-error`;
1751
+ $errorSpan.classList.add('govuk-error-message');
1752
+ $errorSpan.innerHTML = this.escapeHtml(error.message);
1753
+ const $control = document.querySelector(`#${error.fieldName}`);
1754
+ const $fieldset = $control.closest('.govuk-fieldset');
1755
+ const $fieldContainer = ($fieldset || $control).closest('.govuk-form-group');
1756
+ const $label = $fieldContainer.querySelector('label');
1757
+ const $legend = $fieldContainer.querySelector('legend');
1758
+ $fieldContainer.classList.add('govuk-form-group--error');
1759
+ if ($fieldset && $legend) {
1760
+ $legend.after($errorSpan);
1761
+ $fieldContainer.setAttribute('aria-invalid', 'true');
1762
+ addAttributeValue($fieldset, 'aria-describedby', $errorSpan.id);
1763
+ } else if ($label && $control) {
1764
+ $label.after($errorSpan);
1765
+ $control.setAttribute('aria-invalid', 'true');
1766
+ addAttributeValue($control, 'aria-describedby', $errorSpan.id);
1719
1767
  }
1720
1768
  }
1721
1769
  removeInlineErrors() {
@@ -1723,33 +1771,46 @@
1723
1771
  this.removeInlineError(error);
1724
1772
  }
1725
1773
  }
1774
+
1775
+ /**
1776
+ * @param {ValidationError} error
1777
+ */
1726
1778
  removeInlineError(error) {
1727
- const errorSpan = document.querySelector(`#${error.fieldName}-error`);
1728
- const control = document.querySelector(`#${error.fieldName}`);
1729
- const fieldset = control.closest('.govuk-fieldset');
1730
- const fieldContainer = (fieldset || control).closest('.govuk-form-group');
1731
- const label = fieldContainer.querySelector('label');
1732
- const legend = fieldContainer.querySelector('legend');
1733
- errorSpan.remove();
1734
- fieldContainer.classList.remove('govuk-form-group--error');
1735
- if (fieldset && legend) {
1736
- fieldContainer.removeAttribute('aria-invalid');
1737
- removeAttributeValue(fieldset, 'aria-describedby', errorSpan.id);
1738
- } else if (label && control) {
1739
- control.removeAttribute('aria-invalid');
1740
- removeAttributeValue(control, 'aria-describedby', errorSpan.id);
1779
+ const $errorSpan = document.querySelector(`#${error.fieldName}-error`);
1780
+ const $control = document.querySelector(`#${error.fieldName}`);
1781
+ const $fieldset = $control.closest('.govuk-fieldset');
1782
+ const $fieldContainer = ($fieldset || $control).closest('.govuk-form-group');
1783
+ const $label = $fieldContainer.querySelector('label');
1784
+ const $legend = $fieldContainer.querySelector('legend');
1785
+ $errorSpan.remove();
1786
+ $fieldContainer.classList.remove('govuk-form-group--error');
1787
+ if ($fieldset && $legend) {
1788
+ $fieldContainer.removeAttribute('aria-invalid');
1789
+ removeAttributeValue($fieldset, 'aria-describedby', $errorSpan.id);
1790
+ } else if ($label && $control) {
1791
+ $control.removeAttribute('aria-invalid');
1792
+ removeAttributeValue($control, 'aria-describedby', $errorSpan.id);
1741
1793
  }
1742
1794
  }
1795
+
1796
+ /**
1797
+ * @param {string} fieldName - Field name
1798
+ * @param {ValidationRule[]} rules - Validation rules
1799
+ */
1743
1800
  addValidator(fieldName, rules) {
1744
1801
  this.validators.push({
1745
1802
  fieldName,
1746
1803
  rules,
1747
- field: this.form.elements[fieldName]
1804
+ field: this.$root.elements.namedItem(fieldName)
1748
1805
  });
1749
1806
  }
1750
1807
  validate() {
1751
1808
  this.errors = [];
1809
+
1810
+ /** @type {Validator | null} */
1752
1811
  let validator = null;
1812
+
1813
+ /** @type {boolean | string} */
1753
1814
  let validatorReturnValue = true;
1754
1815
  let i;
1755
1816
  let j;
@@ -1774,11 +1835,41 @@
1774
1835
  }
1775
1836
  return this.errors.length === 0;
1776
1837
  }
1838
+
1839
+ /**
1840
+ * @type {Record<string, string>}
1841
+ */
1777
1842
  }
1778
1843
 
1779
1844
  /**
1780
1845
  * @typedef {object} FormValidatorConfig
1781
- * @property {HTMLElement} [summary] - HTML element to use for error summary
1846
+ * @property {object} [summary] - Error summary config
1847
+ * @property {string} [summary.selector] - Selector for error summary
1848
+ * @property {Element | null} [summary.element] - HTML element for error summary
1849
+ */
1850
+
1851
+ /**
1852
+ * @typedef {object} ValidationRule
1853
+ * @property {(field: Validator['field'], params: Record<string, Validator['field']>) => boolean | string} method - Validation method
1854
+ * @property {string} message - Error message
1855
+ * @property {Record<string, Validator['field']>} [params] - Parameters for validation
1856
+ */
1857
+
1858
+ /**
1859
+ * @typedef {object} ValidationError
1860
+ * @property {string} fieldName - Name of the field
1861
+ * @property {string} message - Validation error message
1862
+ */
1863
+
1864
+ /**
1865
+ * @typedef {object} Validator
1866
+ * @property {string} fieldName - Name of the field
1867
+ * @property {ValidationRule[]} rules - Validation rules
1868
+ * @property {Element | RadioNodeList} field - Form field
1869
+ */
1870
+
1871
+ /**
1872
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
1782
1873
  */
1783
1874
  FormValidator.entityMap = {
1784
1875
  '&': '&amp;',
@@ -1790,164 +1881,218 @@
1790
1881
  '`': '&#x60;',
1791
1882
  '=': '&#x3D;'
1792
1883
  };
1884
+ /**
1885
+ * Name for the component used when initialising using data-module attributes.
1886
+ */
1887
+ FormValidator.moduleName = 'moj-form-validator';
1888
+ /**
1889
+ * Multi file upload default config
1890
+ *
1891
+ * @type {FormValidatorConfig}
1892
+ */
1893
+ FormValidator.defaults = Object.freeze({
1894
+ summary: {
1895
+ selector: '.govuk-error-summary'
1896
+ }
1897
+ });
1898
+ /**
1899
+ * Multi file upload config schema
1900
+ *
1901
+ * @satisfies {Schema<FormValidatorConfig>}
1902
+ */
1903
+ FormValidator.schema = Object.freeze(/** @type {const} */{
1904
+ properties: {
1905
+ summary: {
1906
+ type: 'object'
1907
+ }
1908
+ }
1909
+ });
1793
1910
 
1794
1911
  /* eslint-disable @typescript-eslint/no-empty-function */
1795
1912
 
1796
- class MultiFileUpload {
1913
+
1914
+ /**
1915
+ * @augments {ConfigurableComponent<MultiFileUploadConfig>}
1916
+ */
1917
+ class MultiFileUpload extends govukFrontend.ConfigurableComponent {
1797
1918
  /**
1798
- * @param {MultiFileUploadConfig} [params] - Multi file upload config
1919
+ * @param {Element | null} $root - HTML element to use for multi file upload
1920
+ * @param {MultiFileUploadConfig} [config] - Multi file upload config
1799
1921
  */
1800
- constructor(params = {}) {
1801
- const {
1802
- container
1803
- } = params;
1804
- if (!container || !(container instanceof HTMLElement) || !(dragAndDropSupported() && formDataSupported() && fileApiSupported())) {
1922
+ constructor($root, config = {}) {
1923
+ var _this$config$feedback;
1924
+ super($root, config);
1925
+ if (!MultiFileUpload.isSupported()) {
1805
1926
  return this;
1806
1927
  }
1807
- this.container = container;
1808
- this.container.classList.add('moj-multi-file-upload--enhanced');
1809
- this.defaultParams = {
1810
- uploadFileEntryHook: () => {},
1811
- uploadFileExitHook: () => {},
1812
- uploadFileErrorHook: () => {},
1813
- fileDeleteHook: () => {},
1814
- uploadStatusText: 'Uploading files, please wait',
1815
- dropzoneHintText: 'Drag and drop files here or',
1816
- dropzoneButtonText: 'Choose files'
1817
- };
1818
- this.params = Object.assign({}, this.defaultParams, params);
1819
- this.feedbackContainer = /** @type {HTMLDivElement} */
1820
- this.container.querySelector('.moj-multi-file__uploaded-files');
1928
+ const $feedbackContainer = (_this$config$feedback = this.config.feedbackContainer.element) != null ? _this$config$feedback : this.$root.querySelector(this.config.feedbackContainer.selector);
1929
+ if (!$feedbackContainer || !($feedbackContainer instanceof HTMLElement)) {
1930
+ return this;
1931
+ }
1932
+ this.$feedbackContainer = $feedbackContainer;
1821
1933
  this.setupFileInput();
1822
1934
  this.setupDropzone();
1823
1935
  this.setupLabel();
1824
1936
  this.setupStatusBox();
1825
- this.container.addEventListener('click', this.onFileDeleteClick.bind(this));
1937
+ this.$root.addEventListener('click', this.onFileDeleteClick.bind(this));
1938
+ this.$root.classList.add('moj-multi-file-upload--enhanced');
1826
1939
  }
1827
1940
  setupDropzone() {
1828
- this.dropzone = document.createElement('div');
1829
- this.dropzone.classList.add('moj-multi-file-upload__dropzone');
1830
- this.dropzone.addEventListener('dragover', this.onDragOver.bind(this));
1831
- this.dropzone.addEventListener('dragleave', this.onDragLeave.bind(this));
1832
- this.dropzone.addEventListener('drop', this.onDrop.bind(this));
1833
- this.fileInput.replaceWith(this.dropzone);
1834
- this.dropzone.appendChild(this.fileInput);
1941
+ this.$dropzone = document.createElement('div');
1942
+ this.$dropzone.classList.add('moj-multi-file-upload__dropzone');
1943
+ this.$dropzone.addEventListener('dragover', this.onDragOver.bind(this));
1944
+ this.$dropzone.addEventListener('dragleave', this.onDragLeave.bind(this));
1945
+ this.$dropzone.addEventListener('drop', this.onDrop.bind(this));
1946
+ this.$fileInput.replaceWith(this.$dropzone);
1947
+ this.$dropzone.appendChild(this.$fileInput);
1835
1948
  }
1836
1949
  setupLabel() {
1837
- const label = document.createElement('label');
1838
- label.setAttribute('for', this.fileInput.id);
1839
- label.classList.add('govuk-button', 'govuk-button--secondary');
1840
- label.textContent = this.params.dropzoneButtonText;
1841
- const hint = document.createElement('p');
1842
- hint.classList.add('govuk-body');
1843
- hint.textContent = this.params.dropzoneHintText;
1844
- this.label = label;
1845
- this.dropzone.append(hint);
1846
- this.dropzone.append(label);
1950
+ const $label = document.createElement('label');
1951
+ $label.setAttribute('for', this.$fileInput.id);
1952
+ $label.classList.add('govuk-button', 'govuk-button--secondary');
1953
+ $label.textContent = this.config.dropzoneButtonText;
1954
+ const $hint = document.createElement('p');
1955
+ $hint.classList.add('govuk-body');
1956
+ $hint.textContent = this.config.dropzoneHintText;
1957
+ this.$label = $label;
1958
+ this.$dropzone.append($hint);
1959
+ this.$dropzone.append($label);
1847
1960
  }
1848
1961
  setupFileInput() {
1849
- this.fileInput = /** @type {HTMLInputElement} */
1850
- this.container.querySelector('.moj-multi-file-upload__input');
1851
- this.fileInput.addEventListener('change', this.onFileChange.bind(this));
1852
- this.fileInput.addEventListener('focus', this.onFileFocus.bind(this));
1853
- this.fileInput.addEventListener('blur', this.onFileBlur.bind(this));
1962
+ this.$fileInput = /** @type {HTMLInputElement} */
1963
+ this.$root.querySelector('.moj-multi-file-upload__input');
1964
+ this.$fileInput.addEventListener('change', this.onFileChange.bind(this));
1965
+ this.$fileInput.addEventListener('focus', this.onFileFocus.bind(this));
1966
+ this.$fileInput.addEventListener('blur', this.onFileBlur.bind(this));
1854
1967
  }
1855
1968
  setupStatusBox() {
1856
- this.status = document.createElement('div');
1857
- this.status.classList.add('govuk-visually-hidden');
1858
- this.status.setAttribute('aria-live', 'polite');
1859
- this.status.setAttribute('role', 'status');
1860
- this.dropzone.append(this.status);
1969
+ this.$status = document.createElement('div');
1970
+ this.$status.classList.add('govuk-visually-hidden');
1971
+ this.$status.setAttribute('aria-live', 'polite');
1972
+ this.$status.setAttribute('role', 'status');
1973
+ this.$dropzone.append(this.$status);
1861
1974
  }
1975
+
1976
+ /**
1977
+ * @param {DragEvent} event - Drag event
1978
+ */
1862
1979
  onDragOver(event) {
1863
1980
  event.preventDefault();
1864
- this.dropzone.classList.add('moj-multi-file-upload--dragover');
1981
+ this.$dropzone.classList.add('moj-multi-file-upload--dragover');
1865
1982
  }
1866
1983
  onDragLeave() {
1867
- this.dropzone.classList.remove('moj-multi-file-upload--dragover');
1984
+ this.$dropzone.classList.remove('moj-multi-file-upload--dragover');
1868
1985
  }
1986
+
1987
+ /**
1988
+ * @param {DragEvent} event - Drag event
1989
+ */
1869
1990
  onDrop(event) {
1870
1991
  event.preventDefault();
1871
- this.dropzone.classList.remove('moj-multi-file-upload--dragover');
1872
- this.feedbackContainer.classList.remove('moj-hidden');
1873
- this.status.textContent = this.params.uploadStatusText;
1992
+ this.$dropzone.classList.remove('moj-multi-file-upload--dragover');
1993
+ this.$feedbackContainer.classList.remove('moj-hidden');
1994
+ this.$status.textContent = this.config.uploadStatusText;
1874
1995
  this.uploadFiles(event.dataTransfer.files);
1875
1996
  }
1997
+
1998
+ /**
1999
+ * @param {FileList} files - File list
2000
+ */
1876
2001
  uploadFiles(files) {
1877
2002
  for (const file of Array.from(files)) {
1878
2003
  this.uploadFile(file);
1879
2004
  }
1880
2005
  }
1881
2006
  onFileChange() {
1882
- this.feedbackContainer.classList.remove('moj-hidden');
1883
- this.status.textContent = this.params.uploadStatusText;
1884
- this.uploadFiles(this.fileInput.files);
1885
- const fileInput = this.fileInput.cloneNode(true);
1886
- if (!fileInput || !(fileInput instanceof HTMLInputElement)) {
2007
+ this.$feedbackContainer.classList.remove('moj-hidden');
2008
+ this.$status.textContent = this.config.uploadStatusText;
2009
+ this.uploadFiles(this.$fileInput.files);
2010
+ const $fileInput = this.$fileInput.cloneNode(true);
2011
+ if (!$fileInput || !($fileInput instanceof HTMLInputElement)) {
1887
2012
  return;
1888
2013
  }
1889
- fileInput.value = '';
1890
- this.fileInput.replaceWith(fileInput);
2014
+ $fileInput.value = '';
2015
+ this.$fileInput.replaceWith($fileInput);
1891
2016
  this.setupFileInput();
1892
- this.fileInput.focus();
2017
+ this.$fileInput.focus();
1893
2018
  }
1894
2019
  onFileFocus() {
1895
- this.label.classList.add('moj-multi-file-upload--focused');
2020
+ this.$label.classList.add('moj-multi-file-upload--focused');
1896
2021
  }
1897
2022
  onFileBlur() {
1898
- this.label.classList.remove('moj-multi-file-upload--focused');
2023
+ this.$label.classList.remove('moj-multi-file-upload--focused');
1899
2024
  }
2025
+
2026
+ /**
2027
+ * @param {UploadResponseSuccess['success']} success
2028
+ */
1900
2029
  getSuccessHtml(success) {
1901
2030
  return `<span class="moj-multi-file-upload__success"> <svg class="moj-banner__icon" fill="currentColor" role="presentation" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25" height="25" width="25"><path d="M25,6.2L8.7,23.2L0,14.1l4-4.2l4.7,4.9L21,2L25,6.2z"/></svg>${success.messageHtml}</span>`;
1902
2031
  }
2032
+
2033
+ /**
2034
+ * @param {UploadResponseError['error']} error
2035
+ */
1903
2036
  getErrorHtml(error) {
1904
2037
  return `<span class="moj-multi-file-upload__error"> <svg class="moj-banner__icon" fill="currentColor" role="presentation" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25" height="25" width="25"><path d="M13.6,15.4h-2.3v-4.5h2.3V15.4z M13.6,19.8h-2.3v-2.2h2.3V19.8z M0,23.2h25L12.5,2L0,23.2z"/></svg>${error.message}</span>`;
1905
2038
  }
2039
+
2040
+ /**
2041
+ * @param {File} file
2042
+ */
1906
2043
  getFileRow(file) {
1907
- const row = document.createElement('div');
1908
- row.classList.add('govuk-summary-list__row', 'moj-multi-file-upload__row');
1909
- row.innerHTML = `
2044
+ const $row = document.createElement('div');
2045
+ $row.classList.add('govuk-summary-list__row', 'moj-multi-file-upload__row');
2046
+ $row.innerHTML = `
1910
2047
  <div class="govuk-summary-list__value moj-multi-file-upload__message">
1911
2048
  <span class="moj-multi-file-upload__filename">${file.name}</span>
1912
2049
  <span class="moj-multi-file-upload__progress">0%</span>
1913
2050
  </div>
1914
2051
  <div class="govuk-summary-list__actions moj-multi-file-upload__actions"></div>
1915
2052
  `;
1916
- return row;
2053
+ return $row;
1917
2054
  }
2055
+
2056
+ /**
2057
+ * @param {UploadResponseFile} file
2058
+ */
1918
2059
  getDeleteButton(file) {
1919
- const button = document.createElement('button');
1920
- button.setAttribute('type', 'button');
1921
- button.setAttribute('name', 'delete');
1922
- button.setAttribute('value', file.filename);
1923
- button.classList.add('moj-multi-file-upload__delete', 'govuk-button', 'govuk-button--secondary', 'govuk-!-margin-bottom-0');
1924
- button.innerHTML = `Delete <span class="govuk-visually-hidden">${file.originalname}</span>`;
1925
- return button;
2060
+ const $button = document.createElement('button');
2061
+ $button.setAttribute('type', 'button');
2062
+ $button.setAttribute('name', 'delete');
2063
+ $button.setAttribute('value', file.filename);
2064
+ $button.classList.add('moj-multi-file-upload__delete', 'govuk-button', 'govuk-button--secondary', 'govuk-!-margin-bottom-0');
2065
+ $button.innerHTML = `Delete <span class="govuk-visually-hidden">${file.originalname}</span>`;
2066
+ return $button;
1926
2067
  }
2068
+
2069
+ /**
2070
+ * @param {File} file
2071
+ */
1927
2072
  uploadFile(file) {
1928
- this.params.uploadFileEntryHook(this, file);
1929
- const item = this.getFileRow(file);
1930
- const message = item.querySelector('.moj-multi-file-upload__message');
1931
- const actions = item.querySelector('.moj-multi-file-upload__actions');
1932
- const progress = item.querySelector('.moj-multi-file-upload__progress');
2073
+ this.config.hooks.entryHook(this, file);
2074
+ const $item = this.getFileRow(file);
2075
+ const $message = $item.querySelector('.moj-multi-file-upload__message');
2076
+ const $actions = $item.querySelector('.moj-multi-file-upload__actions');
2077
+ const $progress = $item.querySelector('.moj-multi-file-upload__progress');
1933
2078
  const formData = new FormData();
1934
2079
  formData.append('documents', file);
1935
- this.feedbackContainer.querySelector('.moj-multi-file-upload__list').append(item);
2080
+ this.$feedbackContainer.querySelector('.moj-multi-file-upload__list').append($item);
1936
2081
  const xhr = new XMLHttpRequest();
1937
2082
  const onLoad = () => {
1938
2083
  if (xhr.status < 200 || xhr.status >= 300 || !('success' in xhr.response)) {
1939
2084
  return onError();
1940
2085
  }
1941
- message.innerHTML = this.getSuccessHtml(xhr.response.success);
1942
- this.status.textContent = xhr.response.success.messageText;
1943
- actions.append(this.getDeleteButton(xhr.response.file));
1944
- this.params.uploadFileExitHook(this, file, xhr, xhr.responseText);
2086
+ $message.innerHTML = this.getSuccessHtml(xhr.response.success);
2087
+ this.$status.textContent = xhr.response.success.messageText;
2088
+ $actions.append(this.getDeleteButton(xhr.response.file));
2089
+ this.config.hooks.exitHook(this, file, xhr, xhr.responseText);
1945
2090
  };
1946
2091
  const onError = () => {
1947
2092
  const error = new Error(xhr.response && 'error' in xhr.response ? xhr.response.error.message : xhr.statusText || 'Upload failed');
1948
- message.innerHTML = this.getErrorHtml(error);
1949
- this.status.textContent = error.message;
1950
- this.params.uploadFileErrorHook(this, file, xhr, xhr.responseText, error);
2093
+ $message.innerHTML = this.getErrorHtml(error);
2094
+ this.$status.textContent = error.message;
2095
+ this.config.hooks.errorHook(this, file, xhr, xhr.responseText, error);
1951
2096
  };
1952
2097
  xhr.addEventListener('load', onLoad);
1953
2098
  xhr.addEventListener('error', onError);
@@ -1956,15 +2101,19 @@
1956
2101
  return;
1957
2102
  }
1958
2103
  const percentComplete = Math.round(event.loaded / event.total * 100);
1959
- progress.textContent = ` ${percentComplete}%`;
2104
+ $progress.textContent = ` ${percentComplete}%`;
1960
2105
  });
1961
- xhr.open('POST', this.params.uploadUrl);
2106
+ xhr.open('POST', this.config.uploadUrl);
1962
2107
  xhr.responseType = 'json';
1963
2108
  xhr.send(formData);
1964
2109
  }
2110
+
2111
+ /**
2112
+ * @param {MouseEvent} event - Click event
2113
+ */
1965
2114
  onFileDeleteClick(event) {
1966
- const button = event.target;
1967
- if (!button || !(button instanceof HTMLButtonElement) || !button.classList.contains('moj-multi-file-upload__delete')) {
2115
+ const $button = event.target;
2116
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-multi-file-upload__delete')) {
1968
2117
  return;
1969
2118
  }
1970
2119
  event.preventDefault(); // if user refreshes page and then deletes
@@ -1974,155 +2123,371 @@
1974
2123
  if (xhr.status < 200 || xhr.status >= 300) {
1975
2124
  return;
1976
2125
  }
1977
- const rows = Array.from(this.feedbackContainer.querySelectorAll('.moj-multi-file-upload__row'));
1978
- if (rows.length === 1) {
1979
- this.feedbackContainer.classList.add('moj-hidden');
2126
+ const $rows = Array.from(this.$feedbackContainer.querySelectorAll('.moj-multi-file-upload__row'));
2127
+ if ($rows.length === 1) {
2128
+ this.$feedbackContainer.classList.add('moj-hidden');
1980
2129
  }
1981
- const row = rows.find(row => row.contains(button));
1982
- if (row) row.remove();
1983
- this.params.fileDeleteHook(this, undefined, xhr, xhr.responseText);
2130
+ const $rowDelete = $rows.find($row => $row.contains($button));
2131
+ if ($rowDelete) $rowDelete.remove();
2132
+ this.config.hooks.deleteHook(this, undefined, xhr, xhr.responseText);
1984
2133
  });
1985
- xhr.open('POST', this.params.deleteUrl);
2134
+ xhr.open('POST', this.config.deleteUrl);
1986
2135
  xhr.setRequestHeader('Content-Type', 'application/json');
1987
2136
  xhr.responseType = 'json';
1988
2137
  xhr.send(JSON.stringify({
1989
- [button.name]: button.value
2138
+ [$button.name]: $button.value
1990
2139
  }));
1991
2140
  }
2141
+ static isSupported() {
2142
+ return this.isDragAndDropSupported() && this.isFormDataSupported() && this.isFileApiSupported();
2143
+ }
2144
+ static isDragAndDropSupported() {
2145
+ const div = document.createElement('div');
2146
+ return typeof div.ondrop !== 'undefined';
2147
+ }
2148
+ static isFormDataSupported() {
2149
+ return typeof FormData === 'function';
2150
+ }
2151
+ static isFileApiSupported() {
2152
+ const input = document.createElement('input');
2153
+ input.type = 'file';
2154
+ return typeof input.files !== 'undefined';
2155
+ }
2156
+
2157
+ /**
2158
+ * Name for the component used when initialising using data-module attributes.
2159
+ */
1992
2160
  }
1993
2161
 
1994
- class MultiSelect {
1995
- constructor(options) {
1996
- this.container = options.container;
1997
- if (this.container.hasAttribute('data-moj-multi-select-init')) {
2162
+ /**
2163
+ * Multi file upload config
2164
+ *
2165
+ * @typedef {object} MultiFileUploadConfig
2166
+ * @property {string} [uploadUrl] - File upload URL
2167
+ * @property {string} [deleteUrl] - File delete URL
2168
+ * @property {string} [uploadStatusText] - Upload status text
2169
+ * @property {string} [dropzoneHintText] - Dropzone hint text
2170
+ * @property {string} [dropzoneButtonText] - Dropzone button text
2171
+ * @property {object} [feedbackContainer] - Feedback container config
2172
+ * @property {string} [feedbackContainer.selector] - Selector for feedback container
2173
+ * @property {Element | null} [feedbackContainer.element] - HTML element for feedback container
2174
+ * @property {MultiFileUploadHooks} [hooks] - Upload hooks
2175
+ */
2176
+
2177
+ /**
2178
+ * Multi file upload hooks
2179
+ *
2180
+ * @typedef {object} MultiFileUploadHooks
2181
+ * @property {OnUploadFileEntryHook} [entryHook] - File upload entry hook
2182
+ * @property {OnUploadFileExitHook} [exitHook] - File upload exit hook
2183
+ * @property {OnUploadFileErrorHook} [errorHook] - File upload error hook
2184
+ * @property {OnUploadFileDeleteHook} [deleteHook] - File delete hook
2185
+ */
2186
+
2187
+ /**
2188
+ * Upload hook: File entry
2189
+ *
2190
+ * @callback OnUploadFileEntryHook
2191
+ * @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
2192
+ * @param {File} file - File upload
2193
+ */
2194
+
2195
+ /**
2196
+ * Upload hook: File exit
2197
+ *
2198
+ * @callback OnUploadFileExitHook
2199
+ * @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
2200
+ * @param {File} file - File upload
2201
+ * @param {XMLHttpRequest} xhr - XMLHttpRequest
2202
+ * @param {string} textStatus - Text status
2203
+ */
2204
+
2205
+ /**
2206
+ * Upload hook: File error
2207
+ *
2208
+ * @callback OnUploadFileErrorHook
2209
+ * @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
2210
+ * @param {File} file - File upload
2211
+ * @param {XMLHttpRequest} xhr - XMLHttpRequest
2212
+ * @param {string} textStatus - Text status
2213
+ * @param {Error} errorThrown - Error thrown
2214
+ */
2215
+
2216
+ /**
2217
+ * Upload hook: File delete
2218
+ *
2219
+ * @callback OnUploadFileDeleteHook
2220
+ * @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
2221
+ * @param {File} [file] - File upload
2222
+ * @param {XMLHttpRequest} xhr - XMLHttpRequest
2223
+ * @param {string} textStatus - Text status
2224
+ */
2225
+
2226
+ /**
2227
+ * @typedef {object} UploadResponseSuccess
2228
+ * @property {{ messageText: string, messageHtml: string }} success - Response success
2229
+ * @property {UploadResponseFile} file - Response file
2230
+ */
2231
+
2232
+ /**
2233
+ * @typedef {object} UploadResponseError
2234
+ * @property {{ message: string }} error - Response error
2235
+ * @property {UploadResponseFile} file - Response file
2236
+ */
2237
+
2238
+ /**
2239
+ * @typedef {object} UploadResponseFile
2240
+ * @property {string} filename - File name
2241
+ * @property {string} originalname - Original file name
2242
+ */
2243
+
2244
+ /**
2245
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
2246
+ */
2247
+ MultiFileUpload.moduleName = 'moj-multi-file-upload';
2248
+ /**
2249
+ * Multi file upload default config
2250
+ *
2251
+ * @type {MultiFileUploadConfig}
2252
+ */
2253
+ MultiFileUpload.defaults = Object.freeze({
2254
+ uploadStatusText: 'Uploading files, please wait',
2255
+ dropzoneHintText: 'Drag and drop files here or',
2256
+ dropzoneButtonText: 'Choose files',
2257
+ feedbackContainer: {
2258
+ selector: '.moj-multi-file__uploaded-files'
2259
+ },
2260
+ hooks: {
2261
+ entryHook: () => {},
2262
+ exitHook: () => {},
2263
+ errorHook: () => {},
2264
+ deleteHook: () => {}
2265
+ }
2266
+ });
2267
+ /**
2268
+ * Multi file upload config schema
2269
+ *
2270
+ * @satisfies {Schema<MultiFileUploadConfig>}
2271
+ */
2272
+ MultiFileUpload.schema = Object.freeze(/** @type {const} */{
2273
+ properties: {
2274
+ uploadUrl: {
2275
+ type: 'string'
2276
+ },
2277
+ deleteUrl: {
2278
+ type: 'string'
2279
+ },
2280
+ uploadStatusText: {
2281
+ type: 'string'
2282
+ },
2283
+ dropzoneHintText: {
2284
+ type: 'string'
2285
+ },
2286
+ dropzoneButtonText: {
2287
+ type: 'string'
2288
+ },
2289
+ feedbackContainer: {
2290
+ type: 'object'
2291
+ },
2292
+ hooks: {
2293
+ type: 'object'
2294
+ }
2295
+ }
2296
+ });
2297
+
2298
+ /**
2299
+ * @augments {ConfigurableComponent<MultiSelectConfig>}
2300
+ */
2301
+ class MultiSelect extends govukFrontend.ConfigurableComponent {
2302
+ /**
2303
+ * @param {Element | null} $root - HTML element to use for multi select
2304
+ * @param {MultiSelectConfig} [config] - Multi select config
2305
+ */
2306
+ constructor($root, config = {}) {
2307
+ var _this$config$checkbox;
2308
+ super($root, config);
2309
+ const $container = this.$root.querySelector(`#${this.config.idPrefix}select-all`);
2310
+ const $checkboxes = /** @type {NodeListOf<HTMLInputElement>} */(_this$config$checkbox = this.config.checkboxes.items) != null ? _this$config$checkbox : this.$root.querySelectorAll(this.config.checkboxes.selector);
2311
+ if (!$container || !($container instanceof HTMLElement) || !$checkboxes.length) {
1998
2312
  return this;
1999
2313
  }
2000
- this.container.setAttribute('data-moj-multi-select-init', '');
2001
- const idPrefix = options.id_prefix;
2002
- this.setupToggle(idPrefix);
2003
- this.toggleButton = this.toggle.querySelector('input');
2004
- this.toggleButton.addEventListener('click', this.onButtonClick.bind(this));
2005
- this.container.append(this.toggle);
2006
- this.checkboxes = Array.from(options.checkboxes);
2007
- this.checkboxes.forEach(el => el.addEventListener('click', this.onCheckboxClick.bind(this)));
2008
- this.checked = options.checked || false;
2314
+ this.setupToggle(this.config.idPrefix);
2315
+ this.$toggleButton = this.$toggle.querySelector('input');
2316
+ this.$toggleButton.addEventListener('click', this.onButtonClick.bind(this));
2317
+ this.$container = $container;
2318
+ this.$container.append(this.$toggle);
2319
+ this.$checkboxes = Array.from($checkboxes);
2320
+ this.$checkboxes.forEach($input => $input.addEventListener('click', this.onCheckboxClick.bind(this)));
2321
+ this.checked = config.checked || false;
2009
2322
  }
2010
2323
  setupToggle(idPrefix = '') {
2011
2324
  const id = `${idPrefix}checkboxes-all`;
2012
- const toggle = document.createElement('div');
2013
- const label = document.createElement('label');
2014
- const input = document.createElement('input');
2015
- const span = document.createElement('span');
2016
- toggle.classList.add('govuk-checkboxes__item', 'govuk-checkboxes--small', 'moj-multi-select__checkbox');
2017
- input.id = id;
2018
- input.type = 'checkbox';
2019
- input.classList.add('govuk-checkboxes__input');
2020
- label.setAttribute('for', id);
2021
- label.classList.add('govuk-label', 'govuk-checkboxes__label', 'moj-multi-select__toggle-label');
2022
- span.classList.add('govuk-visually-hidden');
2023
- span.textContent = 'Select all';
2024
- label.append(span);
2025
- toggle.append(input, label);
2026
- this.toggle = toggle;
2325
+ const $toggle = document.createElement('div');
2326
+ const $label = document.createElement('label');
2327
+ const $input = document.createElement('input');
2328
+ const $span = document.createElement('span');
2329
+ $toggle.classList.add('govuk-checkboxes__item', 'govuk-checkboxes--small', 'moj-multi-select__checkbox');
2330
+ $input.id = id;
2331
+ $input.type = 'checkbox';
2332
+ $input.classList.add('govuk-checkboxes__input');
2333
+ $label.setAttribute('for', id);
2334
+ $label.classList.add('govuk-label', 'govuk-checkboxes__label', 'moj-multi-select__toggle-label');
2335
+ $span.classList.add('govuk-visually-hidden');
2336
+ $span.textContent = 'Select all';
2337
+ $label.append($span);
2338
+ $toggle.append($input, $label);
2339
+ this.$toggle = $toggle;
2027
2340
  }
2028
2341
  onButtonClick() {
2029
2342
  if (this.checked) {
2030
2343
  this.uncheckAll();
2031
- this.toggleButton.checked = false;
2344
+ this.$toggleButton.checked = false;
2032
2345
  } else {
2033
2346
  this.checkAll();
2034
- this.toggleButton.checked = true;
2347
+ this.$toggleButton.checked = true;
2035
2348
  }
2036
2349
  }
2037
2350
  checkAll() {
2038
- this.checkboxes.forEach(el => {
2039
- el.checked = true;
2351
+ this.$checkboxes.forEach($input => {
2352
+ $input.checked = true;
2040
2353
  });
2041
2354
  this.checked = true;
2042
2355
  }
2043
2356
  uncheckAll() {
2044
- this.checkboxes.forEach(el => {
2045
- el.checked = false;
2357
+ this.$checkboxes.forEach($input => {
2358
+ $input.checked = false;
2046
2359
  });
2047
2360
  this.checked = false;
2048
2361
  }
2362
+
2363
+ /**
2364
+ * @param {MouseEvent} event - Click event
2365
+ */
2049
2366
  onCheckboxClick(event) {
2367
+ if (!(event.target instanceof HTMLInputElement)) {
2368
+ return;
2369
+ }
2050
2370
  if (!event.target.checked) {
2051
- this.toggleButton.checked = false;
2371
+ this.$toggleButton.checked = false;
2052
2372
  this.checked = false;
2053
2373
  } else {
2054
- if (this.checkboxes.filter(el => el.checked).length === this.checkboxes.length) {
2055
- this.toggleButton.checked = true;
2374
+ if (this.$checkboxes.filter($input => $input.checked).length === this.$checkboxes.length) {
2375
+ this.$toggleButton.checked = true;
2056
2376
  this.checked = true;
2057
2377
  }
2058
2378
  }
2059
2379
  }
2060
- }
2061
2380
 
2062
- class PasswordReveal {
2063
2381
  /**
2064
- * @param {Element | null} element - HTML element to use for password reveal
2382
+ * Name for the component used when initialising using data-module attributes.
2065
2383
  */
2066
- constructor(element) {
2067
- if (!element || !(element instanceof HTMLInputElement)) {
2068
- return this;
2384
+ }
2385
+
2386
+ /**
2387
+ * Multi select config
2388
+ *
2389
+ * @typedef {object} MultiSelectConfig
2390
+ * @property {string} [idPrefix] - Prefix for the Select all" checkbox `id` attribute
2391
+ * @property {boolean} [checked] - Whether the "Select all" checkbox is checked
2392
+ * @property {object} [checkboxes] - Checkboxes config
2393
+ * @property {string} [checkboxes.selector] - Checkboxes query selector
2394
+ * @property {NodeListOf<HTMLInputElement>} [checkboxes.items] - Checkboxes query selector results
2395
+ */
2396
+
2397
+ /**
2398
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
2399
+ */
2400
+ MultiSelect.moduleName = 'moj-multi-select';
2401
+ /**
2402
+ * Multi select config
2403
+ *
2404
+ * @type {MultiSelectConfig}
2405
+ */
2406
+ MultiSelect.defaults = Object.freeze({
2407
+ idPrefix: '',
2408
+ checkboxes: {
2409
+ selector: 'tbody input.govuk-checkboxes__input'
2410
+ }
2411
+ });
2412
+ /**
2413
+ * Multi select config schema
2414
+ *
2415
+ * @satisfies {Schema<MultiSelectConfig>}
2416
+ */
2417
+ MultiSelect.schema = Object.freeze(/** @type {const} */{
2418
+ properties: {
2419
+ idPrefix: {
2420
+ type: 'string'
2421
+ },
2422
+ checked: {
2423
+ type: 'boolean'
2424
+ },
2425
+ checkboxes: {
2426
+ type: 'object'
2069
2427
  }
2070
- this.el = element;
2071
- this.container = element.parentElement;
2072
- if (this.container.hasAttribute('data-moj-password-reveal-init')) {
2428
+ }
2429
+ });
2430
+
2431
+ class PasswordReveal extends govukFrontend.Component {
2432
+ /**
2433
+ * @param {Element | null} $root - HTML element to use for password reveal
2434
+ */
2435
+ constructor($root) {
2436
+ super($root);
2437
+ const $input = this.$root.querySelector('.govuk-input');
2438
+ if (!$input || !($input instanceof HTMLInputElement)) {
2073
2439
  return this;
2074
2440
  }
2075
- this.container.setAttribute('data-moj-password-reveal-init', '');
2076
- this.el.setAttribute('spellcheck', 'false');
2441
+ this.$input = $input;
2442
+ this.$input.setAttribute('spellcheck', 'false');
2077
2443
  this.createButton();
2078
2444
  }
2079
2445
  createButton() {
2080
- this.group = document.createElement('div');
2081
- this.button = document.createElement('button');
2082
- this.button.setAttribute('type', 'button');
2083
- this.group.className = 'moj-password-reveal';
2084
- this.button.className = 'govuk-button govuk-button--secondary moj-password-reveal__button';
2085
- this.button.innerHTML = 'Show <span class="govuk-visually-hidden">password</span>';
2086
- this.button.addEventListener('click', this.onButtonClick.bind(this));
2087
- this.group.append(this.el, this.button);
2088
- this.container.append(this.group);
2446
+ this.$group = document.createElement('div');
2447
+ this.$button = document.createElement('button');
2448
+ this.$button.setAttribute('type', 'button');
2449
+ this.$root.classList.add('moj-password-reveal');
2450
+ this.$group.classList.add('moj-password-reveal__wrapper');
2451
+ this.$button.classList.add('govuk-button', 'govuk-button--secondary', 'moj-password-reveal__button');
2452
+ this.$button.innerHTML = 'Show <span class="govuk-visually-hidden">password</span>';
2453
+ this.$button.addEventListener('click', this.onButtonClick.bind(this));
2454
+ this.$group.append(this.$input, this.$button);
2455
+ this.$root.append(this.$group);
2089
2456
  }
2090
2457
  onButtonClick() {
2091
- if (this.el.type === 'password') {
2092
- this.el.type = 'text';
2093
- this.button.innerHTML = 'Hide <span class="govuk-visually-hidden">password</span>';
2458
+ if (this.$input.type === 'password') {
2459
+ this.$input.type = 'text';
2460
+ this.$button.innerHTML = 'Hide <span class="govuk-visually-hidden">password</span>';
2094
2461
  } else {
2095
- this.el.type = 'password';
2096
- this.button.innerHTML = 'Show <span class="govuk-visually-hidden">password</span>';
2462
+ this.$input.type = 'password';
2463
+ this.$button.innerHTML = 'Show <span class="govuk-visually-hidden">password</span>';
2097
2464
  }
2098
2465
  }
2466
+
2467
+ /**
2468
+ * Name for the component used when initialising using data-module attributes.
2469
+ */
2099
2470
  }
2471
+ PasswordReveal.moduleName = 'moj-password-reveal';
2100
2472
 
2101
- class RichTextEditor {
2473
+ /**
2474
+ * @augments {ConfigurableComponent<RichTextEditorConfig>}
2475
+ */
2476
+ class RichTextEditor extends govukFrontend.ConfigurableComponent {
2102
2477
  /**
2103
- * @param {RichTextEditorConfig} options
2478
+ * @param {Element | null} $root - HTML element to use for rich text editor
2479
+ * @param {RichTextEditorConfig} config
2104
2480
  */
2105
- constructor(options = {}) {
2106
- const {
2107
- textarea
2108
- } = options;
2109
- if (!textarea || !textarea.parentElement || !(textarea instanceof HTMLTextAreaElement) || !('contentEditable' in document.documentElement)) {
2481
+ constructor($root, config = {}) {
2482
+ super($root, config);
2483
+ if (!RichTextEditor.isSupported()) {
2110
2484
  return this;
2111
2485
  }
2112
- options.toolbar = options.toolbar || {
2113
- bold: false,
2114
- italic: false,
2115
- underline: false,
2116
- bullets: true,
2117
- numbers: true
2118
- };
2119
- this.textarea = textarea;
2120
- this.container = this.textarea.parentElement;
2121
- this.options = options;
2122
- if (this.container.hasAttribute('data-rich-text-editor-init')) {
2486
+ const $textarea = this.$root.querySelector('.govuk-textarea');
2487
+ if (!$textarea || !($textarea instanceof HTMLTextAreaElement)) {
2123
2488
  return this;
2124
2489
  }
2125
- this.container.setAttribute('data-rich-text-editor-init', '');
2490
+ this.$textarea = $textarea;
2126
2491
  this.createToolbar();
2127
2492
  this.hideDefault();
2128
2493
  this.configureToolbar();
@@ -2132,34 +2497,42 @@
2132
2497
  up: 38,
2133
2498
  down: 40
2134
2499
  };
2135
- this.content.addEventListener('input', this.onEditorInput.bind(this));
2136
- this.container.querySelector('label').addEventListener('click', this.onLabelClick.bind(this));
2137
- this.toolbar.addEventListener('keydown', this.onToolbarKeydown.bind(this));
2500
+ this.$content.addEventListener('input', this.onEditorInput.bind(this));
2501
+ this.$root.querySelector('label').addEventListener('click', this.onLabelClick.bind(this));
2502
+ this.$toolbar.addEventListener('keydown', this.onToolbarKeydown.bind(this));
2138
2503
  }
2504
+
2505
+ /**
2506
+ * @param {KeyboardEvent} event - Click event
2507
+ */
2139
2508
  onToolbarKeydown(event) {
2140
- let focusableButton;
2509
+ let $focusableButton;
2141
2510
  switch (event.keyCode) {
2142
2511
  case this.keys.right:
2143
2512
  case this.keys.down:
2144
2513
  {
2145
- focusableButton = this.buttons.find(button => button.getAttribute('tabindex') === '0');
2146
- const nextButton = focusableButton.nextElementSibling;
2147
- if (nextButton instanceof HTMLButtonElement) {
2148
- nextButton.focus();
2149
- focusableButton.setAttribute('tabindex', '-1');
2150
- nextButton.setAttribute('tabindex', '0');
2514
+ $focusableButton = this.$buttons.find(button => button.getAttribute('tabindex') === '0');
2515
+ if ($focusableButton) {
2516
+ const $nextButton = $focusableButton.nextElementSibling;
2517
+ if ($nextButton && $nextButton instanceof HTMLButtonElement) {
2518
+ $nextButton.focus();
2519
+ $focusableButton.setAttribute('tabindex', '-1');
2520
+ $nextButton.setAttribute('tabindex', '0');
2521
+ }
2151
2522
  }
2152
2523
  break;
2153
2524
  }
2154
2525
  case this.keys.left:
2155
2526
  case this.keys.up:
2156
2527
  {
2157
- focusableButton = this.buttons.find(button => button.getAttribute('tabindex') === '0');
2158
- const previousButton = focusableButton.previousElementSibling;
2159
- if (previousButton instanceof HTMLButtonElement) {
2160
- previousButton.focus();
2161
- focusableButton.setAttribute('tabindex', '-1');
2162
- previousButton.setAttribute('tabindex', '0');
2528
+ $focusableButton = this.$buttons.find(button => button.getAttribute('tabindex') === '0');
2529
+ if ($focusableButton) {
2530
+ const $previousButton = $focusableButton.previousElementSibling;
2531
+ if ($previousButton && $previousButton instanceof HTMLButtonElement) {
2532
+ $previousButton.focus();
2533
+ $focusableButton.setAttribute('tabindex', '-1');
2534
+ $previousButton.setAttribute('tabindex', '0');
2535
+ }
2163
2536
  }
2164
2537
  break;
2165
2538
  }
@@ -2168,19 +2541,19 @@
2168
2541
  getToolbarHtml() {
2169
2542
  let html = '';
2170
2543
  html += '<div class="moj-rich-text-editor__toolbar" role="toolbar">';
2171
- if (this.options.toolbar.bold) {
2544
+ if (this.config.toolbar.bold) {
2172
2545
  html += '<button class="moj-rich-text-editor__toolbar-button moj-rich-text-editor__toolbar-button--bold" type="button" data-command="bold"><span class="govuk-visually-hidden">Bold</span></button>';
2173
2546
  }
2174
- if (this.options.toolbar.italic) {
2547
+ if (this.config.toolbar.italic) {
2175
2548
  html += '<button class="moj-rich-text-editor__toolbar-button moj-rich-text-editor__toolbar-button--italic" type="button" data-command="italic"><span class="govuk-visually-hidden">Italic</span></button>';
2176
2549
  }
2177
- if (this.options.toolbar.underline) {
2550
+ if (this.config.toolbar.underline) {
2178
2551
  html += '<button class="moj-rich-text-editor__toolbar-button moj-rich-text-editor__toolbar-button--underline" type="button" data-command="underline"><span class="govuk-visually-hidden">Underline</span></button>';
2179
2552
  }
2180
- if (this.options.toolbar.bullets) {
2553
+ if (this.config.toolbar.bullets) {
2181
2554
  html += '<button class="moj-rich-text-editor__toolbar-button moj-rich-text-editor__toolbar-button--unordered-list" type="button" data-command="insertUnorderedList"><span class="govuk-visually-hidden">Unordered list</span></button>';
2182
2555
  }
2183
- if (this.options.toolbar.numbers) {
2556
+ if (this.config.toolbar.numbers) {
2184
2557
  html += '<button class="moj-rich-text-editor__toolbar-button moj-rich-text-editor__toolbar-button--ordered-list" type="button" data-command="insertOrderedList"><span class="govuk-visually-hidden">Ordered list</span></button>';
2185
2558
  }
2186
2559
  html += '</div>';
@@ -2190,27 +2563,31 @@
2190
2563
  return `${this.getToolbarHtml()}<div class="govuk-textarea moj-rich-text-editor__content" contenteditable="true" spellcheck="false"></div>`;
2191
2564
  }
2192
2565
  hideDefault() {
2193
- this.textarea.classList.add('govuk-visually-hidden');
2194
- this.textarea.setAttribute('aria-hidden', 'true');
2195
- this.textarea.setAttribute('tabindex', '-1');
2566
+ this.$textarea.classList.add('govuk-visually-hidden');
2567
+ this.$textarea.setAttribute('aria-hidden', 'true');
2568
+ this.$textarea.setAttribute('tabindex', '-1');
2196
2569
  }
2197
2570
  createToolbar() {
2198
- this.toolbar = document.createElement('div');
2199
- this.toolbar.className = 'moj-rich-text-editor';
2200
- this.toolbar.innerHTML = this.getEnhancedHtml();
2201
- this.container.append(this.toolbar);
2202
- this.content = /** @type {HTMLDivElement} */
2203
- this.container.querySelector('.moj-rich-text-editor__content');
2204
- this.content.innerHTML = this.$textarea.value;
2571
+ this.$toolbar = document.createElement('div');
2572
+ this.$toolbar.className = 'moj-rich-text-editor';
2573
+ this.$toolbar.innerHTML = this.getEnhancedHtml();
2574
+ this.$root.append(this.$toolbar);
2575
+ this.$content = /** @type {HTMLElement} */
2576
+ this.$root.querySelector('.moj-rich-text-editor__content');
2577
+ this.$content.innerHTML = this.$textarea.value;
2205
2578
  }
2206
2579
  configureToolbar() {
2207
- this.buttons = Array.from(/** @type {NodeListOf<HTMLButtonElement>} */
2208
- this.container.querySelectorAll('.moj-rich-text-editor__toolbar-button'));
2209
- this.buttons.forEach((button, index) => {
2210
- button.setAttribute('tabindex', !index ? '0' : '-1');
2211
- button.addEventListener('click', this.onButtonClick.bind(this));
2580
+ this.$buttons = Array.from(/** @type {NodeListOf<HTMLButtonElement>} */
2581
+ this.$root.querySelectorAll('.moj-rich-text-editor__toolbar-button'));
2582
+ this.$buttons.forEach(($button, index) => {
2583
+ $button.setAttribute('tabindex', !index ? '0' : '-1');
2584
+ $button.addEventListener('click', this.onButtonClick.bind(this));
2212
2585
  });
2213
2586
  }
2587
+
2588
+ /**
2589
+ * @param {MouseEvent} event - Click event
2590
+ */
2214
2591
  onButtonClick(event) {
2215
2592
  if (!(event.currentTarget instanceof HTMLElement)) {
2216
2593
  return;
@@ -2218,290 +2595,400 @@
2218
2595
  document.execCommand(event.currentTarget.getAttribute('data-command'), false, undefined);
2219
2596
  }
2220
2597
  getContent() {
2221
- return this.content.innerHTML;
2598
+ return this.$content.innerHTML;
2222
2599
  }
2223
2600
  onEditorInput() {
2224
2601
  this.updateTextarea();
2225
2602
  }
2226
2603
  updateTextarea() {
2227
2604
  document.execCommand('defaultParagraphSeparator', false, 'p');
2228
- this.textarea.value = this.getContent();
2605
+ this.$textarea.value = this.getContent();
2229
2606
  }
2607
+
2608
+ /**
2609
+ * @param {MouseEvent} event - Click event
2610
+ */
2230
2611
  onLabelClick(event) {
2231
2612
  event.preventDefault();
2232
- this.content.focus();
2613
+ this.$content.focus();
2614
+ }
2615
+ static isSupported() {
2616
+ return 'contentEditable' in document.documentElement;
2233
2617
  }
2618
+
2619
+ /**
2620
+ * Name for the component used when initialising using data-module attributes.
2621
+ */
2234
2622
  }
2235
2623
 
2236
- class SearchToggle {
2237
- constructor(options) {
2238
- this.options = options;
2239
- this.container = this.options.search.container;
2240
- this.toggleButtonContainer = this.options.toggleButton.container;
2241
- if (this.container.hasAttribute('data-moj-search-toggle-init')) {
2624
+ /**
2625
+ * Rich text editor config
2626
+ *
2627
+ * @typedef {object} RichTextEditorConfig
2628
+ * @property {RichTextEditorToolbar} [toolbar] - Toolbar options
2629
+ */
2630
+
2631
+ /**
2632
+ * Rich text editor toolbar options
2633
+ *
2634
+ * @typedef {object} RichTextEditorToolbar
2635
+ * @property {boolean} [bold] - Show the bold button
2636
+ * @property {boolean} [italic] - Show the italic button
2637
+ * @property {boolean} [underline] - Show the underline button
2638
+ * @property {boolean} [bullets] - Show the bullets button
2639
+ * @property {boolean} [numbers] - Show the numbers button
2640
+ */
2641
+
2642
+ /**
2643
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
2644
+ */
2645
+ RichTextEditor.moduleName = 'moj-rich-text-editor';
2646
+ /**
2647
+ * Rich text editor config
2648
+ *
2649
+ * @type {RichTextEditorConfig}
2650
+ */
2651
+ RichTextEditor.defaults = Object.freeze({
2652
+ toolbar: {
2653
+ bold: false,
2654
+ italic: false,
2655
+ underline: false,
2656
+ bullets: true,
2657
+ numbers: true
2658
+ }
2659
+ });
2660
+ /**
2661
+ * Rich text editor config schema
2662
+ *
2663
+ * @satisfies {Schema<RichTextEditorConfig>}
2664
+ */
2665
+ RichTextEditor.schema = Object.freeze(/** @type {const} */{
2666
+ properties: {
2667
+ toolbar: {
2668
+ type: 'object'
2669
+ }
2670
+ }
2671
+ });
2672
+
2673
+ /**
2674
+ * @augments {ConfigurableComponent<SearchToggleConfig>}
2675
+ */
2676
+ class SearchToggle extends govukFrontend.ConfigurableComponent {
2677
+ /**
2678
+ * @param {Element | null} $root - HTML element to use for search toggle
2679
+ * @param {SearchToggleConfig} [config] - Search toggle config
2680
+ */
2681
+ constructor($root, config = {}) {
2682
+ var _this$config$searchCo, _this$config$toggleBu;
2683
+ super($root, config);
2684
+ const $searchContainer = (_this$config$searchCo = this.config.searchContainer.element) != null ? _this$config$searchCo : this.$root.querySelector(this.config.searchContainer.selector);
2685
+ const $toggleButtonContainer = (_this$config$toggleBu = this.config.toggleButtonContainer.element) != null ? _this$config$toggleBu : this.$root.querySelector(this.config.toggleButtonContainer.selector);
2686
+ if (!$searchContainer || !$toggleButtonContainer || !($searchContainer instanceof HTMLElement) || !($toggleButtonContainer instanceof HTMLElement)) {
2242
2687
  return this;
2243
2688
  }
2244
- this.container.setAttribute('data-moj-search-toggle-init', '');
2689
+ this.$searchContainer = $searchContainer;
2690
+ this.$toggleButtonContainer = $toggleButtonContainer;
2245
2691
  const svg = '<svg viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="moj-search-toggle__button__icon"><path d="M7.433,12.5790048 C6.06762625,12.5808611 4.75763941,12.0392925 3.79217348,11.0738265 C2.82670755,10.1083606 2.28513891,8.79837375 2.28699522,7.433 C2.28513891,6.06762625 2.82670755,4.75763941 3.79217348,3.79217348 C4.75763941,2.82670755 6.06762625,2.28513891 7.433,2.28699522 C8.79837375,2.28513891 10.1083606,2.82670755 11.0738265,3.79217348 C12.0392925,4.75763941 12.5808611,6.06762625 12.5790048,7.433 C12.5808611,8.79837375 12.0392925,10.1083606 11.0738265,11.0738265 C10.1083606,12.0392925 8.79837375,12.5808611 7.433,12.5790048 L7.433,12.5790048 Z M14.293,12.579 L13.391,12.579 L13.071,12.269 C14.2300759,10.9245158 14.8671539,9.20813198 14.866,7.433 C14.866,3.32786745 11.5381325,-1.65045755e-15 7.433,-1.65045755e-15 C3.32786745,-1.65045755e-15 -1.65045755e-15,3.32786745 -1.65045755e-15,7.433 C-1.65045755e-15,11.5381325 3.32786745,14.866 7.433,14.866 C9.208604,14.8671159 10.9253982,14.2296624 12.27,13.07 L12.579,13.39 L12.579,14.294 L18.296,20 L20,18.296 L14.294,12.579 L14.293,12.579 Z"></path></svg>';
2246
- this.toggleButton = document.createElement('button');
2247
- this.toggleButton.setAttribute('class', 'moj-search-toggle__button');
2248
- this.toggleButton.setAttribute('type', 'button');
2249
- this.toggleButton.setAttribute('aria-haspopup', 'true');
2250
- this.toggleButton.setAttribute('aria-expanded', 'false');
2251
- this.toggleButton.innerHTML = `${this.options.toggleButton.text} ${svg}`;
2252
- this.toggleButton.addEventListener('click', this.onToggleButtonClick.bind(this));
2253
- this.toggleButtonContainer.append(this.toggleButton);
2692
+ this.$toggleButton = document.createElement('button');
2693
+ this.$toggleButton.setAttribute('class', 'moj-search-toggle__button');
2694
+ this.$toggleButton.setAttribute('type', 'button');
2695
+ this.$toggleButton.setAttribute('aria-haspopup', 'true');
2696
+ this.$toggleButton.setAttribute('aria-expanded', 'false');
2697
+ this.$toggleButton.innerHTML = `${this.config.toggleButton.text} ${svg}`;
2698
+ this.$toggleButton.addEventListener('click', this.onToggleButtonClick.bind(this));
2699
+ this.$toggleButtonContainer.append(this.$toggleButton);
2254
2700
  document.addEventListener('click', this.onDocumentClick.bind(this));
2255
2701
  document.addEventListener('focusin', this.onDocumentClick.bind(this));
2256
2702
  }
2257
2703
  showMenu() {
2258
- this.toggleButton.setAttribute('aria-expanded', 'true');
2259
- this.container.classList.remove('moj-js-hidden');
2260
- this.container.querySelector('input').focus();
2704
+ this.$toggleButton.setAttribute('aria-expanded', 'true');
2705
+ this.$searchContainer.classList.remove('moj-js-hidden');
2706
+ this.$searchContainer.querySelector('input').focus();
2261
2707
  }
2262
2708
  hideMenu() {
2263
- this.container.classList.add('moj-js-hidden');
2264
- this.toggleButton.setAttribute('aria-expanded', 'false');
2709
+ this.$searchContainer.classList.add('moj-js-hidden');
2710
+ this.$toggleButton.setAttribute('aria-expanded', 'false');
2265
2711
  }
2266
2712
  onToggleButtonClick() {
2267
- if (this.toggleButton.getAttribute('aria-expanded') === 'false') {
2713
+ if (this.$toggleButton.getAttribute('aria-expanded') === 'false') {
2268
2714
  this.showMenu();
2269
2715
  } else {
2270
2716
  this.hideMenu();
2271
2717
  }
2272
2718
  }
2719
+
2720
+ /**
2721
+ * @param {MouseEvent | FocusEvent} event
2722
+ */
2273
2723
  onDocumentClick(event) {
2274
- if (!this.toggleButtonContainer.contains(event.target) && !this.container.contains(event.target)) {
2724
+ if (event.target instanceof Node && !this.$toggleButtonContainer.contains(event.target) && !this.$searchContainer.contains(event.target)) {
2275
2725
  this.hideMenu();
2276
2726
  }
2277
2727
  }
2728
+
2729
+ /**
2730
+ * Name for the component used when initialising using data-module attributes.
2731
+ */
2278
2732
  }
2279
2733
 
2280
- class SortableTable {
2281
- constructor(params) {
2282
- const table = params.table;
2283
- const head = table == null ? void 0 : table.querySelector('thead');
2284
- const body = table == null ? void 0 : table.querySelector('tbody');
2285
- if (!table || !(table instanceof HTMLElement) || !head || !body) {
2286
- return this;
2734
+ /**
2735
+ * @typedef {object} SearchToggleConfig
2736
+ * @property {object} [searchContainer] - Search config
2737
+ * @property {string} [searchContainer.selector] - Selector for search container
2738
+ * @property {Element | null} [searchContainer.element] - HTML element for search container
2739
+ * @property {object} [toggleButton] - Toggle button config
2740
+ * @property {string} [toggleButton.text] - Text for toggle button
2741
+ * @property {object} [toggleButtonContainer] - Toggle button container config
2742
+ * @property {string} [toggleButtonContainer.selector] - Selector for toggle button container
2743
+ * @property {Element | null} [toggleButtonContainer.element] - HTML element for toggle button container
2744
+ */
2745
+
2746
+ /**
2747
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
2748
+ */
2749
+ SearchToggle.moduleName = 'moj-search-toggle';
2750
+ /**
2751
+ * Search toggle config
2752
+ *
2753
+ * @type {SearchToggleConfig}
2754
+ */
2755
+ SearchToggle.defaults = Object.freeze({
2756
+ searchContainer: {
2757
+ selector: '.moj-search'
2758
+ },
2759
+ toggleButton: {
2760
+ text: 'Search'
2761
+ },
2762
+ toggleButtonContainer: {
2763
+ selector: '.moj-search-toggle__toggle'
2764
+ }
2765
+ });
2766
+ /**
2767
+ * Search toggle config schema
2768
+ *
2769
+ * @satisfies {Schema<SearchToggleConfig>}
2770
+ */
2771
+ SearchToggle.schema = Object.freeze(/** @type {const} */{
2772
+ properties: {
2773
+ searchContainer: {
2774
+ type: 'object'
2775
+ },
2776
+ toggleButton: {
2777
+ type: 'object'
2778
+ },
2779
+ toggleButtonContainer: {
2780
+ type: 'object'
2287
2781
  }
2288
- this.table = table;
2289
- this.head = head;
2290
- this.body = body;
2291
- if (this.table.hasAttribute('data-moj-sortable-table-init')) {
2782
+ }
2783
+ });
2784
+
2785
+ /**
2786
+ * @augments {ConfigurableComponent<SortableTableConfig>}
2787
+ */
2788
+ class SortableTable extends govukFrontend.ConfigurableComponent {
2789
+ /**
2790
+ * @param {Element | null} $root - HTML element to use for sortable table
2791
+ * @param {SortableTableConfig} [config] - Sortable table config
2792
+ */
2793
+ constructor($root, config = {}) {
2794
+ super($root, config);
2795
+ const $head = $root == null ? void 0 : $root.querySelector('thead');
2796
+ const $body = $root == null ? void 0 : $root.querySelector('tbody');
2797
+ if (!$head || !$body) {
2292
2798
  return this;
2293
2799
  }
2294
- this.table.setAttribute('data-moj-sortable-table-init', '');
2295
- this.headings = this.head ? Array.from(this.head.querySelectorAll('th')) : [];
2296
- this.setupOptions(params);
2800
+ this.$head = $head;
2801
+ this.$body = $body;
2802
+ this.$headings = this.$head ? Array.from(this.$head.querySelectorAll('th')) : [];
2297
2803
  this.createHeadingButtons();
2298
2804
  this.createStatusBox();
2299
2805
  this.initialiseSortedColumn();
2300
- this.head.addEventListener('click', this.onSortButtonClick.bind(this));
2301
- }
2302
- setupOptions(params) {
2303
- params = params || {};
2304
- this.statusMessage = params.statusMessage || 'Sort by %heading% (%direction%)';
2305
- this.ascendingText = params.ascendingText || 'ascending';
2306
- this.descendingText = params.descendingText || 'descending';
2806
+ this.$head.addEventListener('click', this.onSortButtonClick.bind(this));
2307
2807
  }
2308
2808
  createHeadingButtons() {
2309
- for (const heading of this.headings) {
2310
- if (heading.hasAttribute('aria-sort')) {
2311
- this.createHeadingButton(heading);
2809
+ for (const $heading of this.$headings) {
2810
+ if ($heading.hasAttribute('aria-sort')) {
2811
+ this.createHeadingButton($heading);
2312
2812
  }
2313
2813
  }
2314
2814
  }
2315
- createHeadingButton(heading) {
2316
- const index = this.headings.indexOf(heading);
2317
- const button = document.createElement('button');
2318
- button.setAttribute('type', 'button');
2319
- button.setAttribute('data-index', `${index}`);
2320
- button.textContent = heading.textContent;
2321
- heading.textContent = '';
2322
- heading.appendChild(button);
2815
+
2816
+ /**
2817
+ * @param {HTMLTableCellElement} $heading
2818
+ */
2819
+ createHeadingButton($heading) {
2820
+ const index = this.$headings.indexOf($heading);
2821
+ const $button = document.createElement('button');
2822
+ $button.setAttribute('type', 'button');
2823
+ $button.setAttribute('data-index', `${index}`);
2824
+ $button.textContent = $heading.textContent;
2825
+ $heading.textContent = '';
2826
+ $heading.appendChild($button);
2323
2827
  }
2324
2828
  createStatusBox() {
2325
- this.status = document.createElement('div');
2326
- this.status.setAttribute('aria-atomic', 'true');
2327
- this.status.setAttribute('aria-live', 'polite');
2328
- this.status.setAttribute('class', 'govuk-visually-hidden');
2329
- this.status.setAttribute('role', 'status');
2330
- this.table.insertAdjacentElement('afterend', this.status);
2829
+ this.$status = document.createElement('div');
2830
+ this.$status.setAttribute('aria-atomic', 'true');
2831
+ this.$status.setAttribute('aria-live', 'polite');
2832
+ this.$status.setAttribute('class', 'govuk-visually-hidden');
2833
+ this.$status.setAttribute('role', 'status');
2834
+ this.$root.insertAdjacentElement('afterend', this.$status);
2331
2835
  }
2332
2836
  initialiseSortedColumn() {
2333
- var _sortButton$getAttrib;
2334
- const rows = this.getTableRowsArray();
2335
- const heading = this.table.querySelector('th[aria-sort]');
2336
- const sortButton = heading == null ? void 0 : heading.querySelector('button');
2337
- const sortDirection = heading == null ? void 0 : heading.getAttribute('aria-sort');
2338
- const columnNumber = Number.parseInt((_sortButton$getAttrib = sortButton == null ? void 0 : sortButton.getAttribute('data-index')) != null ? _sortButton$getAttrib : '0', 10);
2339
- if (!heading || !sortButton || !(sortDirection === 'ascending' || sortDirection === 'descending')) {
2837
+ var _$sortButton$getAttri;
2838
+ const $rows = this.getTableRowsArray();
2839
+ const $heading = this.$root.querySelector('th[aria-sort]');
2840
+ const $sortButton = $heading == null ? void 0 : $heading.querySelector('button');
2841
+ const sortDirection = $heading == null ? void 0 : $heading.getAttribute('aria-sort');
2842
+ const columnNumber = Number.parseInt((_$sortButton$getAttri = $sortButton == null ? void 0 : $sortButton.getAttribute('data-index')) != null ? _$sortButton$getAttri : '0', 10);
2843
+ if (!$heading || !$sortButton || !(sortDirection === 'ascending' || sortDirection === 'descending')) {
2340
2844
  return;
2341
2845
  }
2342
- const sortedRows = this.sort(rows, columnNumber, sortDirection);
2343
- this.addRows(sortedRows);
2846
+ const $sortedRows = this.sort($rows, columnNumber, sortDirection);
2847
+ this.addRows($sortedRows);
2344
2848
  }
2849
+
2850
+ /**
2851
+ * @param {MouseEvent} event - Click event
2852
+ */
2345
2853
  onSortButtonClick(event) {
2346
- var _button$getAttribute;
2347
- const button = event.target;
2348
- if (!button || !(button instanceof HTMLButtonElement) || !button.parentElement) {
2854
+ var _$button$getAttribute;
2855
+ const $button = event.target;
2856
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.parentElement) {
2349
2857
  return;
2350
2858
  }
2351
- const heading = button.parentElement;
2352
- const sortDirection = heading.getAttribute('aria-sort');
2353
- const columnNumber = Number.parseInt((_button$getAttribute = button == null ? void 0 : button.getAttribute('data-index')) != null ? _button$getAttribute : '0', 10);
2859
+ const $heading = $button.parentElement;
2860
+ const sortDirection = $heading.getAttribute('aria-sort');
2861
+ const columnNumber = Number.parseInt((_$button$getAttribute = $button == null ? void 0 : $button.getAttribute('data-index')) != null ? _$button$getAttribute : '0', 10);
2354
2862
  const newSortDirection = sortDirection === 'none' || sortDirection === 'descending' ? 'ascending' : 'descending';
2355
- const rows = this.getTableRowsArray();
2356
- const sortedRows = this.sort(rows, columnNumber, newSortDirection);
2357
- this.addRows(sortedRows);
2863
+ const $rows = this.getTableRowsArray();
2864
+ const $sortedRows = this.sort($rows, columnNumber, newSortDirection);
2865
+ this.addRows($sortedRows);
2358
2866
  this.removeButtonStates();
2359
- this.updateButtonState(button, newSortDirection);
2867
+ this.updateButtonState($button, newSortDirection);
2360
2868
  }
2361
- updateButtonState(button, direction) {
2869
+
2870
+ /**
2871
+ * @param {HTMLButtonElement} $button
2872
+ * @param {string} direction
2873
+ */
2874
+ updateButtonState($button, direction) {
2362
2875
  if (!(direction === 'ascending' || direction === 'descending')) {
2363
2876
  return;
2364
2877
  }
2365
- button.parentElement.setAttribute('aria-sort', direction);
2366
- let message = this.statusMessage;
2367
- message = message.replace(/%heading%/, button.textContent);
2368
- message = message.replace(/%direction%/, this[`${direction}Text`]);
2369
- this.status.textContent = message;
2878
+ $button.parentElement.setAttribute('aria-sort', direction);
2879
+ let message = this.config.statusMessage;
2880
+ message = message.replace(/%heading%/, $button.textContent);
2881
+ message = message.replace(/%direction%/, this.config[`${direction}Text`]);
2882
+ this.$status.textContent = message;
2370
2883
  }
2371
2884
  removeButtonStates() {
2372
- for (const heading of this.headings) {
2373
- heading.setAttribute('aria-sort', 'none');
2885
+ for (const $heading of this.$headings) {
2886
+ $heading.setAttribute('aria-sort', 'none');
2374
2887
  }
2375
2888
  }
2376
- addRows(rows) {
2377
- for (const row of rows) {
2378
- this.body.append(row);
2889
+
2890
+ /**
2891
+ * @param {HTMLTableRowElement[]} $rows
2892
+ */
2893
+ addRows($rows) {
2894
+ for (const $row of $rows) {
2895
+ this.$body.append($row);
2379
2896
  }
2380
2897
  }
2381
2898
  getTableRowsArray() {
2382
- return Array.from(this.body.querySelectorAll('tr'));
2899
+ return Array.from(this.$body.querySelectorAll('tr'));
2383
2900
  }
2384
- sort(rows, columnNumber, sortDirection) {
2385
- return rows.sort((rowA, rowB) => {
2386
- const tdA = rowA.querySelectorAll('td, th')[columnNumber];
2387
- const tdB = rowB.querySelectorAll('td, th')[columnNumber];
2388
- if (!tdA || !tdB || !(tdA instanceof HTMLElement) || !(tdB instanceof HTMLElement)) {
2901
+
2902
+ /**
2903
+ * @param {HTMLTableRowElement[]} $rows
2904
+ * @param {number} columnNumber
2905
+ * @param {string} sortDirection
2906
+ */
2907
+ sort($rows, columnNumber, sortDirection) {
2908
+ return $rows.sort(($rowA, $rowB) => {
2909
+ const $tdA = $rowA.querySelectorAll('td, th')[columnNumber];
2910
+ const $tdB = $rowB.querySelectorAll('td, th')[columnNumber];
2911
+ if (!$tdA || !$tdB || !($tdA instanceof HTMLElement) || !($tdB instanceof HTMLElement)) {
2389
2912
  return 0;
2390
2913
  }
2391
- const valueA = sortDirection === 'ascending' ? this.getCellValue(tdA) : this.getCellValue(tdB);
2392
- const valueB = sortDirection === 'ascending' ? this.getCellValue(tdB) : this.getCellValue(tdA);
2914
+ const valueA = sortDirection === 'ascending' ? this.getCellValue($tdA) : this.getCellValue($tdB);
2915
+ const valueB = sortDirection === 'ascending' ? this.getCellValue($tdB) : this.getCellValue($tdA);
2393
2916
  return !(typeof valueA === 'number' && typeof valueB === 'number') ? valueA.toString().localeCompare(valueB.toString()) : valueA - valueB;
2394
2917
  });
2395
2918
  }
2396
- getCellValue(cell) {
2397
- const val = cell.getAttribute('data-sort-value') || cell.innerHTML;
2919
+
2920
+ /**
2921
+ * @param {HTMLElement} $cell
2922
+ */
2923
+ getCellValue($cell) {
2924
+ const val = $cell.getAttribute('data-sort-value') || $cell.innerHTML;
2398
2925
  const valAsNumber = Number(val);
2399
2926
  return Number.isFinite(valAsNumber) ? valAsNumber // Exclude invalid numbers, infinity etc
2400
2927
  : val;
2401
2928
  }
2402
- }
2403
-
2404
- const version = '0.0.0-development';
2405
2929
 
2406
- /* eslint-disable no-new */
2930
+ /**
2931
+ * Name for the component used when initialising using data-module attributes.
2932
+ */
2933
+ }
2407
2934
 
2935
+ /**
2936
+ * Sortable table config
2937
+ *
2938
+ * @typedef {object} SortableTableConfig
2939
+ * @property {string} [statusMessage] - Status message
2940
+ * @property {string} [ascendingText] - Ascending text
2941
+ * @property {string} [descendingText] - Descending text
2942
+ */
2408
2943
 
2409
2944
  /**
2410
- * @param {Config} [config]
2945
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
2411
2946
  */
2412
- function initAll(config) {
2413
- // Set the config to an empty object by default if no config is passed.
2414
- config = typeof config !== 'undefined' ? config : {};
2415
-
2416
- // Allow the user to initialise MOJ Frontend in only certain sections of the page
2417
- // Defaults to the entire document if nothing is set.
2418
- const scope = typeof config.scope !== 'undefined' ? config.scope : document;
2419
- const $addAnothers = scope.querySelectorAll('[data-module="moj-add-another"]');
2420
- $addAnothers.forEach($addAnother => {
2421
- new AddAnother($addAnother);
2422
- });
2423
- const $multiSelects = scope.querySelectorAll('[data-module="moj-multi-select"]');
2424
- $multiSelects.forEach($multiSelect => {
2425
- const containerSelector = $multiSelect.getAttribute('data-multi-select-checkbox');
2426
- if (!($multiSelect instanceof HTMLElement) || !containerSelector) {
2427
- return;
2428
- }
2429
- new MultiSelect({
2430
- container: $multiSelect.querySelector(containerSelector),
2431
- checkboxes: $multiSelect.querySelectorAll('tbody .govuk-checkboxes__input'),
2432
- id_prefix: $multiSelect.getAttribute('data-multi-select-idprefix')
2433
- });
2434
- });
2435
- const $passwordReveals = scope.querySelectorAll('[data-module="moj-password-reveal"]');
2436
- $passwordReveals.forEach($passwordReveal => {
2437
- new PasswordReveal($passwordReveal);
2438
- });
2439
- const $richTextEditors = scope.querySelectorAll('[data-module="moj-rich-text-editor"]');
2440
- $richTextEditors.forEach($richTextEditor => {
2441
- const options = {
2442
- textarea: $richTextEditor
2443
- };
2444
- const toolbarAttr = $richTextEditor.getAttribute('data-moj-rich-text-editor-toolbar');
2445
- if (toolbarAttr) {
2446
- const toolbar = toolbarAttr.split(',');
2447
- options.toolbar = {};
2448
- for (const option of toolbar) {
2449
- if (option === 'bold' || option === 'italic' || option === 'underline' || option === 'bullets' || option === 'numbers') {
2450
- options.toolbar[option] = true;
2451
- }
2452
- }
2947
+ SortableTable.moduleName = 'moj-sortable-table';
2948
+ /**
2949
+ * Sortable table config
2950
+ *
2951
+ * @type {SortableTableConfig}
2952
+ */
2953
+ SortableTable.defaults = Object.freeze({
2954
+ statusMessage: 'Sort by %heading% (%direction%)',
2955
+ ascendingText: 'ascending',
2956
+ descendingText: 'descending'
2957
+ });
2958
+ /**
2959
+ * Sortable table config schema
2960
+ *
2961
+ * @satisfies {Schema<SortableTableConfig>}
2962
+ */
2963
+ SortableTable.schema = Object.freeze(/** @type {const} */{
2964
+ properties: {
2965
+ statusMessage: {
2966
+ type: 'string'
2967
+ },
2968
+ ascendingText: {
2969
+ type: 'string'
2970
+ },
2971
+ descendingText: {
2972
+ type: 'string'
2453
2973
  }
2454
- new RichTextEditor(options);
2455
- });
2456
- const $searchToggles = scope.querySelectorAll('[data-module="moj-search-toggle"]');
2457
- $searchToggles.forEach($searchToggle => {
2458
- new SearchToggle({
2459
- toggleButton: {
2460
- container: $searchToggle.querySelector('.moj-search-toggle__toggle'),
2461
- text: $searchToggle.getAttribute('data-moj-search-toggle-text')
2462
- },
2463
- search: {
2464
- container: $searchToggle.querySelector('.moj-search')
2465
- }
2466
- });
2467
- });
2468
- const $sortableTables = scope.querySelectorAll('[data-module="moj-sortable-table"]');
2469
- $sortableTables.forEach($table => {
2470
- new SortableTable({
2471
- table: $table
2472
- });
2473
- });
2474
- const $datePickers = scope.querySelectorAll('[data-module="moj-date-picker"]');
2475
- $datePickers.forEach($datePicker => {
2476
- new DatePicker($datePicker);
2477
- });
2478
- const $buttonMenus = scope.querySelectorAll('[data-module="moj-button-menu"]');
2479
- $buttonMenus.forEach($buttonmenu => {
2480
- new ButtonMenu($buttonmenu);
2481
- });
2482
- const $alerts = scope.querySelectorAll('[data-module="moj-alert"]');
2483
- $alerts.forEach($alert => {
2484
- new Alert($alert);
2485
- });
2486
- }
2974
+ }
2975
+ });
2487
2976
 
2488
2977
  /**
2489
- * @typedef {object} Config
2490
- * @property {Element} [scope=document] - Scope to query for components
2978
+ * @param {Config} [config]
2491
2979
  */
2980
+ function initAll(config) {
2981
+ for (const Component of [AddAnother, Alert, ButtonMenu, DatePicker, MultiSelect, PasswordReveal, RichTextEditor, SearchToggle, SortableTable]) {
2982
+ govukFrontend.createAll(Component, undefined, config);
2983
+ }
2984
+ }
2492
2985
 
2493
2986
  /**
2494
- * Schema for component config
2495
- *
2496
- * @typedef {object} Schema
2497
- * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
2987
+ * @typedef {Parameters<typeof GOVUKFrontend.initAll>[0]} Config
2498
2988
  */
2499
2989
 
2500
2990
  /**
2501
- * Schema property for component config
2502
- *
2503
- * @typedef {object} SchemaProperty
2504
- * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
2991
+ * @import * as GOVUKFrontend from 'govuk-frontend'
2505
2992
  */
2506
2993
 
2507
2994
  exports.AddAnother = AddAnother;