@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
@@ -1,158 +1,557 @@
1
- class AddAnother {
2
- constructor(container) {
3
- this.container = container;
4
- if (this.container.hasAttribute('data-moj-add-another-init')) {
5
- return this;
1
+ function isInitialised($root, moduleName) {
2
+ return $root instanceof HTMLElement && $root.hasAttribute(`data-${moduleName}-init`);
3
+ }
4
+
5
+ /**
6
+ * Checks if GOV.UK Frontend is supported on this page
7
+ *
8
+ * Some browsers will load and run our JavaScript but GOV.UK Frontend
9
+ * won't be supported.
10
+ *
11
+ * @param {HTMLElement | null} [$scope] - (internal) `<body>` HTML element checked for browser support
12
+ * @returns {boolean} Whether GOV.UK Frontend is supported on this page
13
+ */
14
+ function isSupported($scope = document.body) {
15
+ if (!$scope) {
16
+ return false;
17
+ }
18
+ return $scope.classList.contains('govuk-frontend-supported');
19
+ }
20
+ function isArray(option) {
21
+ return Array.isArray(option);
22
+ }
23
+ function isObject(option) {
24
+ return !!option && typeof option === 'object' && !isArray(option);
25
+ }
26
+ function formatErrorMessage(Component, message) {
27
+ return `${Component.moduleName}: ${message}`;
28
+ }
29
+
30
+ class GOVUKFrontendError extends Error {
31
+ constructor(...args) {
32
+ super(...args);
33
+ this.name = 'GOVUKFrontendError';
34
+ }
35
+ }
36
+ class SupportError extends GOVUKFrontendError {
37
+ /**
38
+ * Checks if GOV.UK Frontend is supported on this page
39
+ *
40
+ * @param {HTMLElement | null} [$scope] - HTML element `<body>` checked for browser support
41
+ */
42
+ constructor($scope = document.body) {
43
+ const supportMessage = 'noModule' in HTMLScriptElement.prototype ? 'GOV.UK Frontend initialised without `<body class="govuk-frontend-supported">` from template `<script>` snippet' : 'GOV.UK Frontend is not supported in this browser';
44
+ super($scope ? supportMessage : 'GOV.UK Frontend initialised without `<script type="module">`');
45
+ this.name = 'SupportError';
46
+ }
47
+ }
48
+ class ConfigError extends GOVUKFrontendError {
49
+ constructor(...args) {
50
+ super(...args);
51
+ this.name = 'ConfigError';
52
+ }
53
+ }
54
+ class ElementError extends GOVUKFrontendError {
55
+ constructor(messageOrOptions) {
56
+ let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
57
+ if (typeof messageOrOptions === 'object') {
58
+ const {
59
+ component,
60
+ identifier,
61
+ element,
62
+ expectedType
63
+ } = messageOrOptions;
64
+ message = identifier;
65
+ message += element ? ` is not of type ${expectedType != null ? expectedType : 'HTMLElement'}` : ' not found';
66
+ message = formatErrorMessage(component, message);
67
+ }
68
+ super(message);
69
+ this.name = 'ElementError';
70
+ }
71
+ }
72
+ class InitError extends GOVUKFrontendError {
73
+ constructor(componentOrMessage) {
74
+ const message = typeof componentOrMessage === 'string' ? componentOrMessage : formatErrorMessage(componentOrMessage, `Root element (\`$root\`) already initialised`);
75
+ super(message);
76
+ this.name = 'InitError';
77
+ }
78
+ }
79
+
80
+ class Component {
81
+ /**
82
+ * Returns the root element of the component
83
+ *
84
+ * @protected
85
+ * @returns {RootElementType} - the root element of component
86
+ */
87
+ get $root() {
88
+ return this._$root;
89
+ }
90
+ constructor($root) {
91
+ this._$root = void 0;
92
+ const childConstructor = this.constructor;
93
+ if (typeof childConstructor.moduleName !== 'string') {
94
+ throw new InitError(`\`moduleName\` not defined in component`);
95
+ }
96
+ if (!($root instanceof childConstructor.elementType)) {
97
+ throw new ElementError({
98
+ element: $root,
99
+ component: childConstructor,
100
+ identifier: 'Root element (`$root`)',
101
+ expectedType: childConstructor.elementType.name
102
+ });
103
+ } else {
104
+ this._$root = $root;
105
+ }
106
+ childConstructor.checkSupport();
107
+ this.checkInitialised();
108
+ const moduleName = childConstructor.moduleName;
109
+ this.$root.setAttribute(`data-${moduleName}-init`, '');
110
+ }
111
+ checkInitialised() {
112
+ const constructor = this.constructor;
113
+ const moduleName = constructor.moduleName;
114
+ if (moduleName && isInitialised(this.$root, moduleName)) {
115
+ throw new InitError(constructor);
116
+ }
117
+ }
118
+ static checkSupport() {
119
+ if (!isSupported()) {
120
+ throw new SupportError();
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * @typedef ChildClass
127
+ * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component
128
+ */
129
+
130
+ /**
131
+ * @typedef {typeof Component & ChildClass} ChildClassConstructor
132
+ */
133
+ Component.elementType = HTMLElement;
134
+
135
+ const configOverride = Symbol.for('configOverride');
136
+ class ConfigurableComponent extends Component {
137
+ [configOverride](param) {
138
+ return {};
139
+ }
140
+
141
+ /**
142
+ * Returns the root element of the component
143
+ *
144
+ * @protected
145
+ * @returns {ConfigurationType} - the root element of component
146
+ */
147
+ get config() {
148
+ return this._config;
149
+ }
150
+ constructor($root, config) {
151
+ super($root);
152
+ this._config = void 0;
153
+ const childConstructor = this.constructor;
154
+ if (!isObject(childConstructor.defaults)) {
155
+ throw new ConfigError(formatErrorMessage(childConstructor, 'Config passed as parameter into constructor but no defaults defined'));
156
+ }
157
+ const datasetConfig = normaliseDataset(childConstructor, this._$root.dataset);
158
+ this._config = mergeConfigs(childConstructor.defaults, config != null ? config : {}, this[configOverride](datasetConfig), datasetConfig);
159
+ }
160
+ }
161
+ function normaliseString(value, property) {
162
+ const trimmedValue = value ? value.trim() : '';
163
+ let output;
164
+ let outputType = property == null ? void 0 : property.type;
165
+ if (!outputType) {
166
+ if (['true', 'false'].includes(trimmedValue)) {
167
+ outputType = 'boolean';
168
+ }
169
+ if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
170
+ outputType = 'number';
171
+ }
172
+ }
173
+ switch (outputType) {
174
+ case 'boolean':
175
+ output = trimmedValue === 'true';
176
+ break;
177
+ case 'number':
178
+ output = Number(trimmedValue);
179
+ break;
180
+ default:
181
+ output = value;
182
+ }
183
+ return output;
184
+ }
185
+ function normaliseDataset(Component, dataset) {
186
+ if (!isObject(Component.schema)) {
187
+ throw new ConfigError(formatErrorMessage(Component, 'Config passed as parameter into constructor but no schema defined'));
188
+ }
189
+ const out = {};
190
+ const entries = Object.entries(Component.schema.properties);
191
+ for (const entry of entries) {
192
+ const [namespace, property] = entry;
193
+ const field = namespace.toString();
194
+ if (field in dataset) {
195
+ out[field] = normaliseString(dataset[field], property);
196
+ }
197
+ if ((property == null ? void 0 : property.type) === 'object') {
198
+ out[field] = extractConfigByNamespace(Component.schema, dataset, namespace);
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+ function mergeConfigs(...configObjects) {
204
+ const formattedConfigObject = {};
205
+ for (const configObject of configObjects) {
206
+ for (const key of Object.keys(configObject)) {
207
+ const option = formattedConfigObject[key];
208
+ const override = configObject[key];
209
+ if (isObject(option) && isObject(override)) {
210
+ formattedConfigObject[key] = mergeConfigs(option, override);
211
+ } else {
212
+ formattedConfigObject[key] = override;
213
+ }
214
+ }
215
+ }
216
+ return formattedConfigObject;
217
+ }
218
+ function extractConfigByNamespace(schema, dataset, namespace) {
219
+ const property = schema.properties[namespace];
220
+ if ((property == null ? void 0 : property.type) !== 'object') {
221
+ return;
222
+ }
223
+ const newObject = {
224
+ [namespace]: {}
225
+ };
226
+ for (const [key, value] of Object.entries(dataset)) {
227
+ let current = newObject;
228
+ const keyParts = key.split('.');
229
+ for (const [index, name] of keyParts.entries()) {
230
+ if (isObject(current)) {
231
+ if (index < keyParts.length - 1) {
232
+ if (!isObject(current[name])) {
233
+ current[name] = {};
234
+ }
235
+ current = current[name];
236
+ } else if (key !== namespace) {
237
+ current[name] = normaliseString(value);
238
+ }
239
+ }
240
+ }
241
+ }
242
+ return newObject[namespace];
243
+ }
244
+
245
+ /**
246
+ * Create all instances of a specific component on the page
247
+ *
248
+ * Uses the `data-module` attribute to find all elements matching the specified
249
+ * component on the page, creating instances of the component object for each
250
+ * of them.
251
+ *
252
+ * Any component errors will be caught and logged to the console.
253
+ *
254
+ * @template {CompatibleClass} ComponentClass
255
+ * @param {ComponentClass} Component - class of the component to create
256
+ * @param {ComponentConfig<ComponentClass>} [config] - Config supplied to component
257
+ * @param {OnErrorCallback<ComponentClass> | Element | Document | CreateAllOptions<ComponentClass> } [createAllOptions] - options for createAll including scope of the document to search within and callback function if error throw by component on init
258
+ * @returns {Array<InstanceType<ComponentClass>>} - array of instantiated components
259
+ */
260
+ function createAll(Component, config, createAllOptions) {
261
+ let $scope = document;
262
+ let onError;
263
+ if (typeof createAllOptions === 'object') {
264
+ var _createAllOptions$sco;
265
+ createAllOptions = createAllOptions;
266
+ $scope = (_createAllOptions$sco = createAllOptions.scope) != null ? _createAllOptions$sco : $scope;
267
+ onError = createAllOptions.onError;
268
+ }
269
+ if (typeof createAllOptions === 'function') {
270
+ onError = createAllOptions;
271
+ }
272
+ if (createAllOptions instanceof HTMLElement) {
273
+ $scope = createAllOptions;
274
+ }
275
+ const $elements = $scope.querySelectorAll(`[data-module="${Component.moduleName}"]`);
276
+ if (!isSupported()) {
277
+ if (onError) {
278
+ onError(new SupportError(), {
279
+ component: Component,
280
+ config
281
+ });
282
+ } else {
283
+ console.log(new SupportError());
284
+ }
285
+ return [];
286
+ }
287
+ return Array.from($elements).map($element => {
288
+ try {
289
+ return typeof config !== 'undefined' ? new Component($element, config) : new Component($element);
290
+ } catch (error) {
291
+ if (onError) {
292
+ onError(error, {
293
+ element: $element,
294
+ component: Component,
295
+ config
296
+ });
297
+ } else {
298
+ console.log(error);
299
+ }
300
+ return null;
6
301
  }
7
- this.container.setAttribute('data-moj-add-another-init', '');
8
- this.container.addEventListener('click', this.onRemoveButtonClick.bind(this));
9
- this.container.addEventListener('click', this.onAddButtonClick.bind(this));
10
- const buttons = this.container.querySelectorAll('.moj-add-another__add-button, moj-add-another__remove-button');
11
- buttons.forEach(button => {
12
- if (!(button instanceof HTMLButtonElement)) {
302
+ }).filter(Boolean);
303
+ }
304
+
305
+ /*
306
+ * This variable is automatically overwritten during builds and releases.
307
+ * It doesn't need to be updated manually.
308
+ */
309
+
310
+ /**
311
+ * MoJ Frontend release version
312
+ *
313
+ * {@link https://github.com/ministryofjustice/moj-frontend/releases}
314
+ */
315
+ const version = '5.1.0';
316
+
317
+ class AddAnother extends Component {
318
+ /**
319
+ * @param {Element | null} $root - HTML element to use for add another
320
+ */
321
+ constructor($root) {
322
+ super($root);
323
+ this.$root.addEventListener('click', this.onRemoveButtonClick.bind(this));
324
+ this.$root.addEventListener('click', this.onAddButtonClick.bind(this));
325
+ const $buttons = this.$root.querySelectorAll('.moj-add-another__add-button, moj-add-another__remove-button');
326
+ $buttons.forEach($button => {
327
+ if (!($button instanceof HTMLButtonElement)) {
13
328
  return;
14
329
  }
15
- button.type = 'button';
330
+ $button.type = 'button';
16
331
  });
17
332
  }
333
+
334
+ /**
335
+ * @param {MouseEvent} event - Click event
336
+ */
18
337
  onAddButtonClick(event) {
19
- const button = event.target;
20
- if (!button || !(button instanceof HTMLButtonElement) || !button.classList.contains('moj-add-another__add-button')) {
338
+ const $button = event.target;
339
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-add-another__add-button')) {
21
340
  return;
22
341
  }
23
- const items = this.getItems();
24
- const item = this.getNewItem();
25
- if (!item || !(item instanceof HTMLElement)) {
342
+ const $items = this.getItems();
343
+ const $item = this.getNewItem();
344
+ if (!$item || !($item instanceof HTMLElement)) {
26
345
  return;
27
346
  }
28
- this.updateAttributes(item, items.length);
29
- this.resetItem(item);
30
- const firstItem = items[0];
31
- if (!this.hasRemoveButton(firstItem)) {
32
- this.createRemoveButton(firstItem);
347
+ this.updateAttributes($item, $items.length);
348
+ this.resetItem($item);
349
+ const $firstItem = $items[0];
350
+ if (!this.hasRemoveButton($firstItem)) {
351
+ this.createRemoveButton($firstItem);
33
352
  }
34
- items[items.length - 1].after(item);
35
- const input = item.querySelector('input, textarea, select');
36
- if (input && input instanceof HTMLInputElement) {
37
- input.focus();
353
+ $items[$items.length - 1].after($item);
354
+ const $input = $item.querySelector('input, textarea, select');
355
+ if ($input && $input instanceof HTMLInputElement) {
356
+ $input.focus();
38
357
  }
39
358
  }
40
- hasRemoveButton(item) {
41
- return item.querySelectorAll('.moj-add-another__remove-button').length;
359
+
360
+ /**
361
+ * @param {HTMLElement} $item - Add another item
362
+ */
363
+ hasRemoveButton($item) {
364
+ return $item.querySelectorAll('.moj-add-another__remove-button').length;
42
365
  }
43
366
  getItems() {
44
- if (!this.container) {
367
+ if (!this.$root) {
45
368
  return [];
46
369
  }
47
- const items = Array.from(this.container.querySelectorAll('.moj-add-another__item'));
48
- return items.filter(item => item instanceof HTMLElement);
370
+ const $items = Array.from(this.$root.querySelectorAll('.moj-add-another__item'));
371
+ return $items.filter(item => item instanceof HTMLElement);
49
372
  }
50
373
  getNewItem() {
51
- const items = this.getItems();
52
- const item = items[0].cloneNode(true);
53
- if (!item || !(item instanceof HTMLElement)) {
374
+ const $items = this.getItems();
375
+ const $item = $items[0].cloneNode(true);
376
+ if (!$item || !($item instanceof HTMLElement)) {
54
377
  return;
55
378
  }
56
- if (!this.hasRemoveButton(item)) {
57
- this.createRemoveButton(item);
379
+ if (!this.hasRemoveButton($item)) {
380
+ this.createRemoveButton($item);
58
381
  }
59
- return item;
382
+ return $item;
60
383
  }
61
- updateAttributes(item, index) {
62
- item.querySelectorAll('[data-name]').forEach(el => {
63
- if (!(el instanceof HTMLInputElement)) {
384
+
385
+ /**
386
+ * @param {HTMLElement} $item - Add another item
387
+ * @param {number} index - Add another item index
388
+ */
389
+ updateAttributes($item, index) {
390
+ $item.querySelectorAll('[data-name]').forEach($input => {
391
+ if (!($input instanceof HTMLInputElement)) {
64
392
  return;
65
393
  }
66
- const name = el.getAttribute('data-name') || '';
67
- const id = el.getAttribute('data-id') || '';
68
- const originalId = el.id;
69
- el.name = name.replace(/%index%/, `${index}`);
70
- el.id = id.replace(/%index%/, `${index}`);
71
- const label = el.parentElement.querySelector('label') || el.closest('label') || item.querySelector(`[for="${originalId}"]`);
72
- if (label && label instanceof HTMLLabelElement) {
73
- label.htmlFor = el.id;
394
+ const name = $input.getAttribute('data-name') || '';
395
+ const id = $input.getAttribute('data-id') || '';
396
+ const originalId = $input.id;
397
+ $input.name = name.replace(/%index%/, `${index}`);
398
+ $input.id = id.replace(/%index%/, `${index}`);
399
+ const $label = $input.parentElement.querySelector('label') || $input.closest('label') || $item.querySelector(`[for="${originalId}"]`);
400
+ if ($label && $label instanceof HTMLLabelElement) {
401
+ $label.htmlFor = $input.id;
74
402
  }
75
403
  });
76
404
  }
77
- createRemoveButton(item) {
78
- const button = document.createElement('button');
79
- button.type = 'button';
80
- button.classList.add('govuk-button', 'govuk-button--secondary', 'moj-add-another__remove-button');
81
- button.textContent = 'Remove';
82
- item.append(button);
405
+
406
+ /**
407
+ * @param {HTMLElement} $item - Add another item
408
+ */
409
+ createRemoveButton($item) {
410
+ const $button = document.createElement('button');
411
+ $button.type = 'button';
412
+ $button.classList.add('govuk-button', 'govuk-button--secondary', 'moj-add-another__remove-button');
413
+ $button.textContent = 'Remove';
414
+ $item.append($button);
83
415
  }
84
- resetItem(item) {
85
- item.querySelectorAll('[data-name], [data-id]').forEach(el => {
86
- if (!(el instanceof HTMLInputElement)) {
416
+
417
+ /**
418
+ * @param {HTMLElement} $item - Add another item
419
+ */
420
+ resetItem($item) {
421
+ $item.querySelectorAll('[data-name], [data-id]').forEach($input => {
422
+ if (!($input instanceof HTMLInputElement)) {
87
423
  return;
88
424
  }
89
- if (el.type === 'checkbox' || el.type === 'radio') {
90
- el.checked = false;
425
+ if ($input.type === 'checkbox' || $input.type === 'radio') {
426
+ $input.checked = false;
91
427
  } else {
92
- el.value = '';
428
+ $input.value = '';
93
429
  }
94
430
  });
95
431
  }
432
+
433
+ /**
434
+ * @param {MouseEvent} event - Click event
435
+ */
96
436
  onRemoveButtonClick(event) {
97
- const button = event.target;
98
- if (!button || !(button instanceof HTMLButtonElement) || !button.classList.contains('moj-add-another__remove-button')) {
437
+ const $button = event.target;
438
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-add-another__remove-button')) {
99
439
  return;
100
440
  }
101
- button.closest('.moj-add-another__item').remove();
102
- const items = this.getItems();
103
- if (items.length === 1) {
104
- items[0].querySelector('.moj-add-another__remove-button').remove();
441
+ $button.closest('.moj-add-another__item').remove();
442
+ const $items = this.getItems();
443
+ if ($items.length === 1) {
444
+ $items[0].querySelector('.moj-add-another__remove-button').remove();
105
445
  }
106
- items.forEach((el, index) => {
107
- this.updateAttributes(el, index);
446
+ $items.forEach(($item, index) => {
447
+ this.updateAttributes($item, index);
108
448
  });
109
449
  this.focusHeading();
110
450
  }
111
451
  focusHeading() {
112
- const heading = this.container.querySelector('.moj-add-another__heading');
113
- if (heading && heading instanceof HTMLElement) {
114
- heading.focus();
452
+ const $heading = this.$root.querySelector('.moj-add-another__heading');
453
+ if ($heading && $heading instanceof HTMLElement) {
454
+ $heading.focus();
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Name for the component used when initialising using data-module attributes.
460
+ */
461
+ }
462
+ AddAnother.moduleName = 'moj-add-another';
463
+
464
+ /**
465
+ * GOV.UK Frontend helpers
466
+ *
467
+ * @todo Import from GOV.UK Frontend
468
+ */
469
+
470
+ /**
471
+ * Move focus to element
472
+ *
473
+ * Sets tabindex to -1 to make the element programmatically focusable,
474
+ * but removes it on blur as the element doesn't need to be focused again.
475
+ *
476
+ * @template {HTMLElement} FocusElement
477
+ * @param {FocusElement} $element - HTML element
478
+ * @param {object} [options] - Handler options
479
+ * @param {function(this: FocusElement): void} [options.onBeforeFocus] - Callback before focus
480
+ * @param {function(this: FocusElement): void} [options.onBlur] - Callback on blur
481
+ */
482
+ function setFocus($element, options = {}) {
483
+ var _options$onBeforeFocu;
484
+ const isFocusable = $element.getAttribute('tabindex');
485
+ if (!isFocusable) {
486
+ $element.setAttribute('tabindex', '-1');
487
+ }
488
+
489
+ /**
490
+ * Handle element focus
491
+ */
492
+ function onFocus() {
493
+ $element.addEventListener('blur', onBlur, {
494
+ once: true
495
+ });
496
+ }
497
+
498
+ /**
499
+ * Handle element blur
500
+ */
501
+ function onBlur() {
502
+ var _options$onBlur;
503
+ (_options$onBlur = options.onBlur) == null || _options$onBlur.call($element);
504
+ if (!isFocusable) {
505
+ $element.removeAttribute('tabindex');
115
506
  }
116
507
  }
508
+
509
+ // Add listener to reset element on blur, after focus
510
+ $element.addEventListener('focus', onFocus, {
511
+ once: true
512
+ });
513
+
514
+ // Focus element
515
+ (_options$onBeforeFocu = options.onBeforeFocus) == null || _options$onBeforeFocu.call($element);
516
+ $element.focus();
117
517
  }
118
518
 
119
- function removeAttributeValue(el, attr, value) {
519
+ /**
520
+ * @param {Element} $element - Element to remove attribute value from
521
+ * @param {string} attr - Attribute name
522
+ * @param {string} value - Attribute value
523
+ */
524
+ function removeAttributeValue($element, attr, value) {
120
525
  let re, m;
121
- if (el.getAttribute(attr)) {
122
- if (el.getAttribute(attr) === value) {
123
- el.removeAttribute(attr);
526
+ if ($element.getAttribute(attr)) {
527
+ if ($element.getAttribute(attr) === value) {
528
+ $element.removeAttribute(attr);
124
529
  } else {
125
530
  re = new RegExp(`(^|\\s)${value}(\\s|$)`);
126
- m = el.getAttribute(attr).match(re);
531
+ m = $element.getAttribute(attr).match(re);
127
532
  if (m && m.length === 3) {
128
- el.setAttribute(attr, el.getAttribute(attr).replace(re, m[1] && m[2] ? ' ' : ''));
533
+ $element.setAttribute(attr, $element.getAttribute(attr).replace(re, m[1] && m[2] ? ' ' : ''));
129
534
  }
130
535
  }
131
536
  }
132
537
  }
133
- function addAttributeValue(el, attr, value) {
538
+
539
+ /**
540
+ * @param {Element} $element - Element to add attribute value to
541
+ * @param {string} attr - Attribute name
542
+ * @param {string} value - Attribute value
543
+ */
544
+ function addAttributeValue($element, attr, value) {
134
545
  let re;
135
- if (!el.getAttribute(attr)) {
136
- el.setAttribute(attr, value);
546
+ if (!$element.getAttribute(attr)) {
547
+ $element.setAttribute(attr, value);
137
548
  } else {
138
549
  re = new RegExp(`(^|\\s)${value}(\\s|$)`);
139
- if (!re.test(el.getAttribute(attr))) {
140
- el.setAttribute(attr, `${el.getAttribute(attr)} ${value}`);
550
+ if (!re.test($element.getAttribute(attr))) {
551
+ $element.setAttribute(attr, `${$element.getAttribute(attr)} ${value}`);
141
552
  }
142
553
  }
143
554
  }
144
- function dragAndDropSupported() {
145
- const div = document.createElement('div');
146
- return typeof div.ondrop !== 'undefined';
147
- }
148
- function formDataSupported() {
149
- return typeof FormData === 'function';
150
- }
151
- function fileApiSupported() {
152
- const input = document.createElement('input');
153
- input.type = 'file';
154
- return typeof input.files !== 'undefined';
155
- }
156
555
 
157
556
  /**
158
557
  * Find an elements preceding sibling
@@ -213,89 +612,15 @@ function findNearestMatchingElement($element, selector) {
213
612
  }
214
613
 
215
614
  /**
216
- * Move focus to element
217
- *
218
- * Sets tabindex to -1 to make the element programmatically focusable,
219
- * but removes it on blur as the element doesn't need to be focused again.
220
- *
221
- * @param {HTMLElement} $element - HTML element
222
- * @param {object} [options] - Handler options
223
- * @param {function(this: HTMLElement): void} [options.onBeforeFocus] - Callback before focus
224
- * @param {function(this: HTMLElement): void} [options.onBlur] - Callback on blur
615
+ * @augments {ConfigurableComponent<AlertConfig>}
225
616
  */
226
- function setFocus($element, options = {}) {
227
- const isFocusable = $element.getAttribute('tabindex');
228
- if (!isFocusable) {
229
- $element.setAttribute('tabindex', '-1');
230
- }
231
-
617
+ class Alert extends ConfigurableComponent {
232
618
  /**
233
- * Handle element focus
234
- */
235
- function onFocus() {
236
- $element.addEventListener('blur', onBlur, {
237
- once: true
238
- });
239
- }
240
-
241
- /**
242
- * Handle element blur
243
- */
244
- function onBlur() {
245
- if (options.onBlur) {
246
- options.onBlur.call($element);
247
- }
248
- if (!isFocusable) {
249
- $element.removeAttribute('tabindex');
250
- }
251
- }
252
-
253
- // Add listener to reset element on blur, after focus
254
- $element.addEventListener('focus', onFocus, {
255
- once: true
256
- });
257
-
258
- // Focus element
259
- if (options.onBeforeFocus) {
260
- options.onBeforeFocus.call($element);
261
- }
262
- $element.focus();
263
- }
264
-
265
- class Alert {
266
- /**
267
- * @param {Element | null} $module - HTML element to use for alert
619
+ * @param {Element | null} $root - HTML element to use for alert
268
620
  * @param {AlertConfig} [config] - Alert config
269
621
  */
270
- constructor($module, config = {}) {
271
- if (!$module || !($module instanceof HTMLElement)) {
272
- return this;
273
- }
274
- const schema = Object.freeze({
275
- properties: {
276
- dismissible: {
277
- type: 'boolean'
278
- },
279
- dismissText: {
280
- type: 'string'
281
- },
282
- disableAutoFocus: {
283
- type: 'boolean'
284
- },
285
- focusOnDismissSelector: {
286
- type: 'string'
287
- }
288
- }
289
- });
290
- const defaults = {
291
- dismissible: false,
292
- dismissText: 'Dismiss',
293
- disableAutoFocus: false
294
- };
295
-
296
- // data attributes override JS config, which overrides defaults
297
- this.config = this.mergeConfigs(defaults, config, this.parseDataset(schema, $module.dataset));
298
- this.$module = $module;
622
+ constructor($root, config = {}) {
623
+ super($root, config);
299
624
 
300
625
  /**
301
626
  * Focus the alert
@@ -308,14 +633,14 @@ class Alert {
308
633
  * do this based on user research findings, or to avoid a clash with another
309
634
  * element which should be focused when the page loads.
310
635
  */
311
- if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
312
- setFocus(this.$module);
636
+ if (this.$root.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
637
+ setFocus(this.$root);
313
638
  }
314
- this.$dismissButton = this.$module.querySelector('.moj-alert__dismiss');
639
+ this.$dismissButton = this.$root.querySelector('.moj-alert__dismiss');
315
640
  if (this.config.dismissible && this.$dismissButton) {
316
641
  this.$dismissButton.innerHTML = this.config.dismissText;
317
642
  this.$dismissButton.removeAttribute('hidden');
318
- this.$module.addEventListener('click', event => {
643
+ this.$root.addEventListener('click', event => {
319
644
  if (event.target instanceof Node && this.$dismissButton.contains(event.target)) {
320
645
  this.dimiss();
321
646
  }
@@ -336,7 +661,7 @@ class Alert {
336
661
 
337
662
  // Is the next sibling another alert
338
663
  if (!$elementToRecieveFocus) {
339
- const $nextSibling = this.$module.nextElementSibling;
664
+ const $nextSibling = this.$root.nextElementSibling;
340
665
  if ($nextSibling && $nextSibling.matches('.moj-alert')) {
341
666
  $elementToRecieveFocus = $nextSibling;
342
667
  }
@@ -344,13 +669,13 @@ class Alert {
344
669
 
345
670
  // Else try to find any preceding sibling alert or heading
346
671
  if (!$elementToRecieveFocus) {
347
- $elementToRecieveFocus = getPreviousSibling(this.$module, '.moj-alert, h1, h2, h3, h4, h5, h6');
672
+ $elementToRecieveFocus = getPreviousSibling(this.$root, '.moj-alert, h1, h2, h3, h4, h5, h6');
348
673
  }
349
674
 
350
675
  // Else find the closest ancestor heading, or fallback to main, or last resort
351
676
  // use the body element
352
677
  if (!$elementToRecieveFocus) {
353
- $elementToRecieveFocus = findNearestMatchingElement(this.$module, 'h1, h2, h3, h4, h5, h6, main, body');
678
+ $elementToRecieveFocus = findNearestMatchingElement(this.$root, 'h1, h2, h3, h4, h5, h6, main, body');
354
679
  }
355
680
 
356
681
  // If we have an element, place focus on it
@@ -359,111 +684,12 @@ class Alert {
359
684
  }
360
685
 
361
686
  // Remove the alert
362
- this.$module.remove();
687
+ this.$root.remove();
363
688
  }
364
689
 
365
690
  /**
366
- * Normalise string
367
- *
368
- * 'If it looks like a duck, and it quacks like a duck…' 🦆
369
- *
370
- * If the passed value looks like a boolean or a number, convert it to a boolean
371
- * or number.
372
- *
373
- * Designed to be used to convert config passed via data attributes (which are
374
- * always strings) into something sensible.
375
- *
376
- * @internal
377
- * @param {DOMStringMap[string]} value - The value to normalise
378
- * @param {SchemaProperty} [property] - Component schema property
379
- * @returns {string | boolean | number | undefined} Normalised data
380
- */
381
- normaliseString(value, property) {
382
- const trimmedValue = value ? value.trim() : '';
383
- let output;
384
- let outputType;
385
- if (property && property.type) {
386
- outputType = property.type;
387
- }
388
-
389
- // No schema type set? Determine automatically
390
- if (!outputType) {
391
- if (['true', 'false'].includes(trimmedValue)) {
392
- outputType = 'boolean';
393
- }
394
-
395
- // Empty / whitespace-only strings are considered finite so we need to check
396
- // the length of the trimmed string as well
397
- if (trimmedValue.length > 0 && Number.isFinite(Number(trimmedValue))) {
398
- outputType = 'number';
399
- }
400
- }
401
- switch (outputType) {
402
- case 'boolean':
403
- output = trimmedValue === 'true';
404
- break;
405
- case 'number':
406
- output = Number(trimmedValue);
407
- break;
408
- default:
409
- output = value;
410
- }
411
- return output;
412
- }
413
-
414
- /**
415
- * Parse dataset
416
- *
417
- * Loop over an object and normalise each value using {@link normaliseString},
418
- * optionally expanding nested `i18n.field`
419
- *
420
- * @param {Schema} schema - component schema
421
- * @param {DOMStringMap} dataset - HTML element dataset
422
- * @returns {object} Normalised dataset
423
- */
424
- parseDataset(schema, dataset) {
425
- const parsed = {};
426
- for (const [field, property] of Object.entries(schema.properties)) {
427
- if (field in dataset) {
428
- if (dataset[field]) {
429
- parsed[field] = this.normaliseString(dataset[field], property);
430
- }
431
- }
432
- }
433
- return parsed;
434
- }
435
-
436
- /**
437
- * Config merging function
438
- *
439
- * Takes any number of objects and combines them together, with
440
- * greatest priority on the LAST item passed in.
441
- *
442
- * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
443
- * @returns {{ [key: string]: unknown }} A merged config object
444
- */
445
- mergeConfigs(...configObjects) {
446
- const formattedConfigObject = {};
447
-
448
- // Loop through each of the passed objects
449
- for (const configObject of configObjects) {
450
- for (const key of Object.keys(configObject)) {
451
- const option = formattedConfigObject[key];
452
- const override = configObject[key];
453
-
454
- // Push their keys one-by-one into formattedConfigObject. Any duplicate
455
- // keys with object values will be merged, otherwise the new value will
456
- // override the existing value.
457
- if (typeof option === 'object' && typeof override === 'object') {
458
- // @ts-expect-error Index signature for type 'string' is missing
459
- formattedConfigObject[key] = this.mergeConfigs(option, override);
460
- } else {
461
- formattedConfigObject[key] = override;
462
- }
463
- }
464
- }
465
- return formattedConfigObject;
466
- }
691
+ * Name for the component used when initialising using data-module attributes.
692
+ */
467
693
  }
468
694
 
469
695
  /**
@@ -474,71 +700,87 @@ class Alert {
474
700
  * @property {string} [focusOnDismissSelector] - CSS Selector for element to be focused on dismiss
475
701
  */
476
702
 
477
- class ButtonMenu {
703
+ /**
704
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
705
+ */
706
+ Alert.moduleName = 'moj-alert';
707
+ /**
708
+ * Alert default config
709
+ *
710
+ * @type {AlertConfig}
711
+ */
712
+ Alert.defaults = Object.freeze({
713
+ dismissible: false,
714
+ dismissText: 'Dismiss',
715
+ disableAutoFocus: false
716
+ });
717
+ /**
718
+ * Alert config schema
719
+ *
720
+ * @satisfies {Schema<AlertConfig>}
721
+ */
722
+ Alert.schema = Object.freeze(/** @type {const} */{
723
+ properties: {
724
+ dismissible: {
725
+ type: 'boolean'
726
+ },
727
+ dismissText: {
728
+ type: 'string'
729
+ },
730
+ disableAutoFocus: {
731
+ type: 'boolean'
732
+ },
733
+ focusOnDismissSelector: {
734
+ type: 'string'
735
+ }
736
+ }
737
+ });
738
+
739
+ /**
740
+ * @augments {ConfigurableComponent<ButtonMenuConfig>}
741
+ */
742
+ class ButtonMenu extends ConfigurableComponent {
478
743
  /**
479
- * @param {Element | null} $module - HTML element to use for button menu
744
+ * @param {Element | null} $root - HTML element to use for button menu
480
745
  * @param {ButtonMenuConfig} [config] - Button menu config
481
746
  */
482
- constructor($module, config = {}) {
483
- if (!$module || !($module instanceof HTMLElement)) {
484
- return this;
485
- }
486
- const schema = Object.freeze({
487
- properties: {
488
- buttonText: {
489
- type: 'string'
490
- },
491
- buttonClasses: {
492
- type: 'string'
493
- },
494
- alignMenu: {
495
- type: 'string'
496
- }
497
- }
498
- });
499
- const defaults = {
500
- buttonText: 'Actions',
501
- alignMenu: 'left',
502
- buttonClasses: ''
503
- };
504
- // data attributes override JS config, which overrides defaults
505
- this.config = this.mergeConfigs(defaults, config, this.parseDataset(schema, $module.dataset));
506
- this.$module = $module;
747
+ constructor($root, config = {}) {
748
+ super($root, config);
507
749
 
508
750
  // If only one button is provided, don't initiate a menu and toggle button
509
751
  // if classes have been provided for the toggleButton, apply them to the single item
510
- if (this.$module.children.length === 1) {
511
- const button = this.$module.children[0];
512
- button.classList.forEach(className => {
752
+ if (this.$root.children.length === 1) {
753
+ const $button = this.$root.children[0];
754
+ $button.classList.forEach(className => {
513
755
  if (className.startsWith('govuk-button-')) {
514
- button.classList.remove(className);
756
+ $button.classList.remove(className);
515
757
  }
516
- button.classList.remove('moj-button-menu__item');
517
- button.classList.add('moj-button-menu__single-button');
758
+ $button.classList.remove('moj-button-menu__item');
759
+ $button.classList.add('moj-button-menu__single-button');
518
760
  });
519
761
  if (this.config.buttonClasses) {
520
- button.classList.add(...this.config.buttonClasses.split(' '));
762
+ $button.classList.add(...this.config.buttonClasses.split(' '));
521
763
  }
522
764
  }
523
- // Otherwise intialise a button menu
524
- if (this.$module.children.length > 1) {
765
+ // Otherwise initialise a button menu
766
+ if (this.$root.children.length > 1) {
525
767
  this.initMenu();
526
768
  }
527
769
  }
528
770
  initMenu() {
529
771
  this.$menu = this.createMenu();
530
- this.$module.insertAdjacentHTML('afterbegin', this.toggleTemplate());
772
+ this.$root.insertAdjacentHTML('afterbegin', this.toggleTemplate());
531
773
  this.setupMenuItems();
532
- this.$menuToggle = this.$module.querySelector(':scope > button');
533
- this.items = this.$menu.querySelectorAll('a, button');
774
+ this.$menuToggle = this.$root.querySelector(':scope > button');
775
+ this.$items = this.$menu.querySelectorAll('a, button');
534
776
  this.$menuToggle.addEventListener('click', event => {
535
777
  this.toggleMenu(event);
536
778
  });
537
- this.$module.addEventListener('keydown', event => {
779
+ this.$root.addEventListener('keydown', event => {
538
780
  this.handleKeyDown(event);
539
781
  });
540
782
  document.addEventListener('click', event => {
541
- if (!this.$module.contains(event.target)) {
783
+ if (event.target instanceof Node && !this.$root.contains(event.target)) {
542
784
  this.closeMenu(false);
543
785
  }
544
786
  });
@@ -551,30 +793,30 @@ class ButtonMenu {
551
793
  if (this.config.alignMenu === 'right') {
552
794
  $menu.classList.add('moj-button-menu__wrapper--right');
553
795
  }
554
- this.$module.appendChild($menu);
555
- while (this.$module.firstChild !== $menu) {
556
- $menu.appendChild(this.$module.firstChild);
796
+ this.$root.appendChild($menu);
797
+ while (this.$root.firstChild !== $menu) {
798
+ $menu.appendChild(this.$root.firstChild);
557
799
  }
558
800
  return $menu;
559
801
  }
560
802
  setupMenuItems() {
561
- Array.from(this.$menu.children).forEach(item => {
803
+ Array.from(this.$menu.children).forEach($menuItem => {
562
804
  // wrap item in li tag
563
- const listItem = document.createElement('li');
564
- this.$menu.insertBefore(listItem, item);
565
- listItem.appendChild(item);
566
- item.setAttribute('tabindex', '-1');
567
- if (item.tagName === 'BUTTON') {
568
- item.setAttribute('type', 'button');
805
+ const $listItem = document.createElement('li');
806
+ this.$menu.insertBefore($listItem, $menuItem);
807
+ $listItem.appendChild($menuItem);
808
+ $menuItem.setAttribute('tabindex', '-1');
809
+ if ($menuItem.tagName === 'BUTTON') {
810
+ $menuItem.setAttribute('type', 'button');
569
811
  }
570
- item.classList.forEach(className => {
812
+ $menuItem.classList.forEach(className => {
571
813
  if (className.startsWith('govuk-button')) {
572
- item.classList.remove(className);
814
+ $menuItem.classList.remove(className);
573
815
  }
574
816
  });
575
817
 
576
818
  // add a slight delay after click before closing the menu, makes it *feel* better
577
- item.addEventListener('click', () => {
819
+ $menuItem.addEventListener('click', () => {
578
820
  setTimeout(() => {
579
821
  this.closeMenu(false);
580
822
  }, 50);
@@ -599,6 +841,10 @@ class ButtonMenu {
599
841
  isOpen() {
600
842
  return this.$menuToggle.getAttribute('aria-expanded') === 'true';
601
843
  }
844
+
845
+ /**
846
+ * @param {MouseEvent} event - Click event
847
+ */
602
848
  toggleMenu(event) {
603
849
  event.preventDefault();
604
850
 
@@ -644,18 +890,22 @@ class ButtonMenu {
644
890
  * @param {number} index - the index of the item to focus
645
891
  */
646
892
  focusItem(index) {
647
- if (index >= this.items.length) index = 0;
648
- if (index < 0) index = this.items.length - 1;
649
- const menuItem = this.items.item(index);
650
- if (menuItem) {
651
- menuItem.focus();
893
+ if (index >= this.$items.length) index = 0;
894
+ if (index < 0) index = this.$items.length - 1;
895
+ const $menuItem = this.$items.item(index);
896
+ if ($menuItem) {
897
+ $menuItem.focus();
652
898
  }
653
899
  }
654
900
  currentFocusIndex() {
655
- const activeElement = document.activeElement;
656
- const menuItems = Array.from(this.items);
657
- return menuItems.indexOf(activeElement);
901
+ const $activeElement = document.activeElement;
902
+ const $menuItems = Array.from(this.$items);
903
+ return ($activeElement instanceof HTMLAnchorElement || $activeElement instanceof HTMLButtonElement) && $menuItems.indexOf($activeElement);
658
904
  }
905
+
906
+ /**
907
+ * @param {KeyboardEvent} event - Keydown event
908
+ */
659
909
  handleKeyDown(event) {
660
910
  if (event.target === this.$menuToggle) {
661
911
  switch (event.key) {
@@ -665,11 +915,11 @@ class ButtonMenu {
665
915
  break;
666
916
  case 'ArrowUp':
667
917
  event.preventDefault();
668
- this.openMenu(this.items.length - 1);
918
+ this.openMenu(this.$items.length - 1);
669
919
  break;
670
920
  }
671
921
  }
672
- if (this.$menu.contains(event.target) && this.isOpen()) {
922
+ if (event.target instanceof Node && this.$menu.contains(event.target) && this.isOpen()) {
673
923
  switch (event.key) {
674
924
  case 'ArrowDown':
675
925
  event.preventDefault();
@@ -689,7 +939,7 @@ class ButtonMenu {
689
939
  break;
690
940
  case 'End':
691
941
  event.preventDefault();
692
- this.focusItem(this.items.length - 1);
942
+ this.focusItem(this.$items.length - 1);
693
943
  break;
694
944
  }
695
945
  }
@@ -702,58 +952,8 @@ class ButtonMenu {
702
952
  }
703
953
 
704
954
  /**
705
- * Parse dataset
706
- *
707
- * Loop over an object and normalise each value using {@link normaliseString},
708
- * optionally expanding nested `i18n.field`
709
- *
710
- * @param {Schema} schema - component schema
711
- * @param {DOMStringMap} dataset - HTML element dataset
712
- * @returns {object} Normalised dataset
713
- */
714
- parseDataset(schema, dataset) {
715
- const parsed = {};
716
- for (const [field,,] of Object.entries(schema.properties)) {
717
- if (field in dataset) {
718
- if (dataset[field]) {
719
- parsed[field] = dataset[field];
720
- }
721
- }
722
- }
723
- return parsed;
724
- }
725
-
726
- /**
727
- * Config merging function
728
- *
729
- * Takes any number of objects and combines them together, with
730
- * greatest priority on the LAST item passed in.
731
- *
732
- * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
733
- * @returns {{ [key: string]: unknown }} A merged config object
734
- */
735
- mergeConfigs(...configObjects) {
736
- const formattedConfigObject = {};
737
-
738
- // Loop through each of the passed objects
739
- for (const configObject of configObjects) {
740
- for (const key of Object.keys(configObject)) {
741
- const option = formattedConfigObject[key];
742
- const override = configObject[key];
743
-
744
- // Push their keys one-by-one into formattedConfigObject. Any duplicate
745
- // keys with object values will be merged, otherwise the new value will
746
- // override the existing value.
747
- if (typeof option === 'object' && typeof override === 'object') {
748
- // @ts-expect-error Index signature for type 'string' is missing
749
- formattedConfigObject[key] = this.mergeConfigs(option, override);
750
- } else {
751
- formattedConfigObject[key] = override;
752
- }
753
- }
754
- }
755
- return formattedConfigObject;
756
- }
955
+ * Name for the component used when initialising using data-module attributes.
956
+ */
757
957
  }
758
958
 
759
959
  /**
@@ -763,69 +963,68 @@ class ButtonMenu {
763
963
  * @property {string} [buttonClasses='govuk-button--secondary'] - css classes applied to the toggle button
764
964
  */
765
965
 
766
- class DatePicker {
966
+ /**
967
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
968
+ */
969
+ ButtonMenu.moduleName = 'moj-button-menu';
970
+ /**
971
+ * Button menu config
972
+ *
973
+ * @type {ButtonMenuConfig}
974
+ */
975
+ ButtonMenu.defaults = Object.freeze({
976
+ buttonText: 'Actions',
977
+ alignMenu: 'left',
978
+ buttonClasses: ''
979
+ });
980
+ /**
981
+ * Button menu config schema
982
+ *
983
+ * @type {Schema<ButtonMenuConfig>}
984
+ */
985
+ ButtonMenu.schema = Object.freeze(/** @type {const} */{
986
+ properties: {
987
+ buttonText: {
988
+ type: 'string'
989
+ },
990
+ buttonClasses: {
991
+ type: 'string'
992
+ },
993
+ alignMenu: {
994
+ type: 'string'
995
+ }
996
+ }
997
+ });
998
+
999
+ /**
1000
+ * @augments {ConfigurableComponent<DatePickerConfig>}
1001
+ */
1002
+ class DatePicker extends ConfigurableComponent {
767
1003
  /**
768
- * @param {Element | null} $module - HTML element to use for date picker
1004
+ * @param {Element | null} $root - HTML element to use for date picker
769
1005
  * @param {DatePickerConfig} [config] - Date picker config
770
1006
  */
771
- constructor($module, config = {}) {
772
- if (!$module || !($module instanceof HTMLElement)) {
773
- return this;
774
- }
775
- const $input = $module.querySelector('.moj-js-datepicker-input');
776
-
777
- // Check that required elements are present
1007
+ constructor($root, config = {}) {
1008
+ var _this$config$input$el;
1009
+ super($root, config);
1010
+ const $input = (_this$config$input$el = this.config.input.element) != null ? _this$config$input$el : this.$root.querySelector(this.config.input.selector);
778
1011
  if (!$input || !($input instanceof HTMLInputElement)) {
779
1012
  return this;
780
1013
  }
781
- this.$module = $module;
782
1014
  this.$input = $input;
783
- const schema = Object.freeze({
784
- properties: {
785
- excludedDates: {
786
- type: 'string'
787
- },
788
- excludedDays: {
789
- type: 'string'
790
- },
791
- leadingZeros: {
792
- type: 'string'
793
- },
794
- maxDate: {
795
- type: 'string'
796
- },
797
- minDate: {
798
- type: 'string'
799
- },
800
- weekStartDay: {
801
- type: 'string'
802
- }
803
- }
804
- });
805
- const defaults = {
806
- leadingZeros: false,
807
- weekStartDay: 'monday'
808
- };
809
-
810
- // data attributes override JS config, which overrides defaults
811
- this.config = this.mergeConfigs(defaults, config, this.parseDataset(schema, $module.dataset));
812
1015
  this.dayLabels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
813
1016
  this.monthLabels = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
814
1017
  this.currentDate = new Date();
815
1018
  this.currentDate.setHours(0, 0, 0, 0);
816
- this.calendarDays = [];
817
- this.excludedDates = [];
818
- this.excludedDays = [];
1019
+ this.calendarDays = /** @type {DSCalendarDay[]} */[];
1020
+ this.excludedDates = /** @type {Date[]} */[];
1021
+ this.excludedDays = /** @type {number[]} */[];
819
1022
  this.buttonClass = 'moj-datepicker__button';
820
1023
  this.selectedDayButtonClass = 'moj-datepicker__button--selected';
821
1024
  this.currentDayButtonClass = 'moj-datepicker__button--current';
822
1025
  this.todayButtonClass = 'moj-datepicker__button--today';
823
- if (this.$module.dataset.initialized) {
824
- return this;
825
- }
826
1026
  this.setOptions();
827
1027
  this.initControls();
828
- this.$module.setAttribute('data-initialized', 'true');
829
1028
  }
830
1029
  initControls() {
831
1030
  this.id = `datepicker-${this.$input.id}`;
@@ -840,15 +1039,23 @@ class DatePicker {
840
1039
  $inputWrapper.appendChild(this.$input);
841
1040
  $inputWrapper.insertAdjacentHTML('beforeend', this.toggleTemplate());
842
1041
  $componentWrapper.insertAdjacentElement('beforeend', this.$dialog);
843
- this.$calendarButton = this.$module.querySelector('.moj-js-datepicker-toggle');
844
- this.$dialogTitle = this.$dialog.querySelector('.moj-js-datepicker-month-year');
1042
+ this.$calendarButton = /** @type {HTMLButtonElement} */
1043
+ this.$root.querySelector('.moj-js-datepicker-toggle');
1044
+ this.$dialogTitle = /** @type {HTMLHeadingElement} */
1045
+ this.$dialog.querySelector('.moj-js-datepicker-month-year');
845
1046
  this.createCalendar();
846
- this.$prevMonthButton = this.$dialog.querySelector('.moj-js-datepicker-prev-month');
847
- this.$prevYearButton = this.$dialog.querySelector('.moj-js-datepicker-prev-year');
848
- this.$nextMonthButton = this.$dialog.querySelector('.moj-js-datepicker-next-month');
849
- this.$nextYearButton = this.$dialog.querySelector('.moj-js-datepicker-next-year');
850
- this.$cancelButton = this.$dialog.querySelector('.moj-js-datepicker-cancel');
851
- this.$okButton = this.$dialog.querySelector('.moj-js-datepicker-ok');
1047
+ this.$prevMonthButton = /** @type {HTMLButtonElement} */
1048
+ this.$dialog.querySelector('.moj-js-datepicker-prev-month');
1049
+ this.$prevYearButton = /** @type {HTMLButtonElement} */
1050
+ this.$dialog.querySelector('.moj-js-datepicker-prev-year');
1051
+ this.$nextMonthButton = /** @type {HTMLButtonElement} */
1052
+ this.$dialog.querySelector('.moj-js-datepicker-next-month');
1053
+ this.$nextYearButton = /** @type {HTMLButtonElement} */
1054
+ this.$dialog.querySelector('.moj-js-datepicker-next-year');
1055
+ this.$cancelButton = /** @type {HTMLButtonElement} */
1056
+ this.$dialog.querySelector('.moj-js-datepicker-cancel');
1057
+ this.$okButton = /** @type {HTMLButtonElement} */
1058
+ this.$dialog.querySelector('.moj-js-datepicker-ok');
852
1059
 
853
1060
  // add event listeners
854
1061
  this.$prevMonthButton.addEventListener('click', event => this.focusPreviousMonth(event, false));
@@ -862,9 +1069,9 @@ class DatePicker {
862
1069
  this.$okButton.addEventListener('click', () => {
863
1070
  this.selectDate(this.currentDate);
864
1071
  });
865
- const dialogButtons = this.$dialog.querySelectorAll('button:not([disabled="true"])');
866
- this.$firstButtonInDialog = dialogButtons[0];
867
- this.$lastButtonInDialog = dialogButtons[dialogButtons.length - 1];
1072
+ const $dialogButtons = this.$dialog.querySelectorAll('button:not([disabled="true"])');
1073
+ this.$firstButtonInDialog = $dialogButtons[0];
1074
+ this.$lastButtonInDialog = $dialogButtons[$dialogButtons.length - 1];
868
1075
  this.$firstButtonInDialog.addEventListener('keydown', event => this.firstButtonKeydown(event));
869
1076
  this.$lastButtonInDialog.addEventListener('keydown', event => this.lastButtonKeydown(event));
870
1077
  this.$calendarButton.addEventListener('click', event => this.toggleDialog(event));
@@ -1010,7 +1217,6 @@ class DatePicker {
1010
1217
  this.setMinAndMaxDatesOnCalendar();
1011
1218
  this.setExcludedDates();
1012
1219
  this.setExcludedDays();
1013
- this.setLeadingZeros();
1014
1220
  this.setWeekStartDay();
1015
1221
  }
1016
1222
  setMinAndMaxDatesOnCalendar() {
@@ -1035,10 +1241,10 @@ class DatePicker {
1035
1241
  }
1036
1242
  }
1037
1243
 
1038
- /*
1244
+ /**
1039
1245
  * Parses a daterange string into an array of dates
1040
- * @param {String} datestring - A daterange string in the format "dd/mm/yyyy-dd/mm/yyyy"
1041
- * @returns {Date[]}
1246
+ *
1247
+ * @param {string} datestring - A daterange string in the format "dd/mm/yyyy-dd/mm/yyyy"
1042
1248
  */
1043
1249
  parseDateRangeString(datestring) {
1044
1250
  const dates = [];
@@ -1065,17 +1271,6 @@ class DatePicker {
1065
1271
  this.excludedDays = this.config.excludedDays.replace(/\s+/, ' ').toLowerCase().split(' ').map(item => weekDays.indexOf(item)).filter(item => item !== -1);
1066
1272
  }
1067
1273
  }
1068
- setLeadingZeros() {
1069
- if (typeof this.config.leadingZeros !== 'boolean') {
1070
- if (this.config.leadingZeros.toLowerCase() === 'true') {
1071
- this.config.leadingZeros = true;
1072
- return;
1073
- }
1074
- if (this.config.leadingZeros.toLowerCase() === 'false') {
1075
- this.config.leadingZeros = false;
1076
- }
1077
- }
1078
- }
1079
1274
  setWeekStartDay() {
1080
1275
  const weekStartDayParam = this.config.weekStartDay;
1081
1276
  if (weekStartDayParam && weekStartDayParam.toLowerCase() === 'sunday') {
@@ -1088,7 +1283,7 @@ class DatePicker {
1088
1283
  }
1089
1284
 
1090
1285
  /**
1091
- * Determine if a date is selecteable
1286
+ * Determine if a date is selectable
1092
1287
  *
1093
1288
  * @param {Date} date - the date to check
1094
1289
  * @returns {boolean}
@@ -1154,24 +1349,36 @@ class DatePicker {
1154
1349
  /**
1155
1350
  * Get a human readable date in the format Monday 2 March 2024
1156
1351
  *
1157
- * @param {Date} date - date to format
1352
+ * @param {Date} date - Date to format
1158
1353
  * @returns {string}
1159
1354
  */
1160
1355
  formattedDateHuman(date) {
1161
1356
  return `${this.dayLabels[(date.getDay() + 6) % 7]} ${date.getDate()} ${this.monthLabels[date.getMonth()]} ${date.getFullYear()}`;
1162
1357
  }
1358
+
1359
+ /**
1360
+ * @param {MouseEvent} event - Click event
1361
+ */
1163
1362
  backgroundClick(event) {
1164
1363
  if (this.isOpen() && event.target instanceof Node && !this.$dialog.contains(event.target) && !this.$input.contains(event.target) && !this.$calendarButton.contains(event.target)) {
1165
1364
  event.preventDefault();
1166
1365
  this.closeDialog();
1167
1366
  }
1168
1367
  }
1368
+
1369
+ /**
1370
+ * @param {KeyboardEvent} event - Keydown event
1371
+ */
1169
1372
  firstButtonKeydown(event) {
1170
1373
  if (event.key === 'Tab' && event.shiftKey) {
1171
1374
  this.$lastButtonInDialog.focus();
1172
1375
  event.preventDefault();
1173
1376
  }
1174
1377
  }
1378
+
1379
+ /**
1380
+ * @param {KeyboardEvent} event - Keydown event
1381
+ */
1175
1382
  lastButtonKeydown(event) {
1176
1383
  if (event.key === 'Tab' && !event.shiftKey) {
1177
1384
  this.$firstButtonInDialog.focus();
@@ -1201,49 +1408,57 @@ class DatePicker {
1201
1408
  thisDay.setDate(thisDay.getDate() + 1);
1202
1409
  }
1203
1410
  }
1411
+
1412
+ /**
1413
+ * @param {boolean} [focus] - Focus the day button
1414
+ */
1204
1415
  setCurrentDate(focus = true) {
1205
1416
  const {
1206
1417
  currentDate
1207
1418
  } = this;
1208
1419
  this.calendarDays.forEach(calendarDay => {
1209
- calendarDay.button.classList.add('moj-datepicker__button');
1210
- calendarDay.button.classList.add('moj-datepicker__calendar-day');
1211
- calendarDay.button.setAttribute('tabindex', '-1');
1212
- calendarDay.button.classList.remove(this.selectedDayButtonClass);
1420
+ calendarDay.$button.classList.add('moj-datepicker__button');
1421
+ calendarDay.$button.classList.add('moj-datepicker__calendar-day');
1422
+ calendarDay.$button.setAttribute('tabindex', '-1');
1423
+ calendarDay.$button.classList.remove(this.selectedDayButtonClass);
1213
1424
  const calendarDayDate = calendarDay.date;
1214
1425
  calendarDayDate.setHours(0, 0, 0, 0);
1215
1426
  const today = new Date();
1216
1427
  today.setHours(0, 0, 0, 0);
1217
1428
  if (calendarDayDate.getTime() === currentDate.getTime() /* && !calendarDay.button.disabled */) {
1218
1429
  if (focus) {
1219
- calendarDay.button.setAttribute('tabindex', '0');
1220
- calendarDay.button.focus();
1221
- calendarDay.button.classList.add(this.selectedDayButtonClass);
1430
+ calendarDay.$button.setAttribute('tabindex', '0');
1431
+ calendarDay.$button.focus();
1432
+ calendarDay.$button.classList.add(this.selectedDayButtonClass);
1222
1433
  }
1223
1434
  }
1224
1435
  if (this.inputDate && calendarDayDate.getTime() === this.inputDate.getTime()) {
1225
- calendarDay.button.classList.add(this.currentDayButtonClass);
1226
- calendarDay.button.setAttribute('aria-current', 'date');
1436
+ calendarDay.$button.classList.add(this.currentDayButtonClass);
1437
+ calendarDay.$button.setAttribute('aria-current', 'date');
1227
1438
  } else {
1228
- calendarDay.button.classList.remove(this.currentDayButtonClass);
1229
- calendarDay.button.removeAttribute('aria-current');
1439
+ calendarDay.$button.classList.remove(this.currentDayButtonClass);
1440
+ calendarDay.$button.removeAttribute('aria-current');
1230
1441
  }
1231
1442
  if (calendarDayDate.getTime() === today.getTime()) {
1232
- calendarDay.button.classList.add(this.todayButtonClass);
1443
+ calendarDay.$button.classList.add(this.todayButtonClass);
1233
1444
  } else {
1234
- calendarDay.button.classList.remove(this.todayButtonClass);
1445
+ calendarDay.$button.classList.remove(this.todayButtonClass);
1235
1446
  }
1236
1447
  });
1237
1448
 
1238
1449
  // if no date is tab-able, make the first non-disabled date tab-able
1239
1450
  if (!focus) {
1240
1451
  const enabledDays = this.calendarDays.filter(calendarDay => {
1241
- return window.getComputedStyle(calendarDay.button).display === 'block' && !calendarDay.button.disabled;
1452
+ return window.getComputedStyle(calendarDay.$button).display === 'block' && !calendarDay.$button.disabled;
1242
1453
  });
1243
- enabledDays[0].button.setAttribute('tabindex', '0');
1454
+ enabledDays[0].$button.setAttribute('tabindex', '0');
1244
1455
  this.currentDate = enabledDays[0].date;
1245
1456
  }
1246
1457
  }
1458
+
1459
+ /**
1460
+ * @param {Date} date - Date to select
1461
+ */
1247
1462
  selectDate(date) {
1248
1463
  if (this.isExcludedDate(date)) {
1249
1464
  return;
@@ -1260,6 +1475,10 @@ class DatePicker {
1260
1475
  isOpen() {
1261
1476
  return this.$dialog.classList.contains('moj-datepicker__dialog--open');
1262
1477
  }
1478
+
1479
+ /**
1480
+ * @param {MouseEvent} event - Click event
1481
+ */
1263
1482
  toggleDialog(event) {
1264
1483
  event.preventDefault();
1265
1484
  if (this.isOpen()) {
@@ -1294,6 +1513,11 @@ class DatePicker {
1294
1513
  this.$calendarButton.setAttribute('aria-expanded', 'false');
1295
1514
  this.$calendarButton.focus();
1296
1515
  }
1516
+
1517
+ /**
1518
+ * @param {Date} date - Date to go to
1519
+ * @param {boolean} [focus] - Focus the day button
1520
+ */
1297
1521
  goToDate(date, focus) {
1298
1522
  const current = this.currentDate;
1299
1523
  this.currentDate = date;
@@ -1345,13 +1569,23 @@ class DatePicker {
1345
1569
  this.goToDate(date);
1346
1570
  }
1347
1571
 
1348
- // month navigation
1572
+ /**
1573
+ * Month navigation
1574
+ *
1575
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1576
+ * @param {boolean} [focus] - Focus the day button
1577
+ */
1349
1578
  focusNextMonth(event, focus = true) {
1350
1579
  event.preventDefault();
1351
1580
  const date = new Date(this.currentDate);
1352
1581
  date.setMonth(date.getMonth() + 1, 1);
1353
1582
  this.goToDate(date, focus);
1354
1583
  }
1584
+
1585
+ /**
1586
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1587
+ * @param {boolean} [focus] - Focus the day button
1588
+ */
1355
1589
  focusPreviousMonth(event, focus = true) {
1356
1590
  event.preventDefault();
1357
1591
  const date = new Date(this.currentDate);
@@ -1359,13 +1593,23 @@ class DatePicker {
1359
1593
  this.goToDate(date, focus);
1360
1594
  }
1361
1595
 
1362
- // year navigation
1596
+ /**
1597
+ * Year navigation
1598
+ *
1599
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1600
+ * @param {boolean} [focus] - Focus the day button
1601
+ */
1363
1602
  focusNextYear(event, focus = true) {
1364
1603
  event.preventDefault();
1365
1604
  const date = new Date(this.currentDate);
1366
1605
  date.setFullYear(date.getFullYear() + 1, date.getMonth(), 1);
1367
1606
  this.goToDate(date, focus);
1368
1607
  }
1608
+
1609
+ /**
1610
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1611
+ * @param {boolean} [focus] - Focus the day button
1612
+ */
1369
1613
  focusPreviousYear(event, focus = true) {
1370
1614
  event.preventDefault();
1371
1615
  const date = new Date(this.currentDate);
@@ -1374,72 +1618,70 @@ class DatePicker {
1374
1618
  }
1375
1619
 
1376
1620
  /**
1377
- * Parse dataset
1378
- *
1379
- * @param {Schema} schema - Component class
1380
- * @param {DOMStringMap} dataset - HTML element dataset
1381
- * @returns {object} Normalised dataset
1382
- */
1383
- parseDataset(schema, dataset) {
1384
- const parsed = {};
1385
- for (const [field,,] of Object.entries(schema.properties)) {
1386
- if (field in dataset) {
1387
- parsed[field] = dataset[field];
1388
- }
1389
- }
1390
- return parsed;
1391
- }
1392
-
1393
- /**
1394
- * Config merging function
1395
- *
1396
- * Takes any number of objects and combines them together, with
1397
- * greatest priority on the LAST item passed in.
1398
- *
1399
- * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
1400
- * @returns {{ [key: string]: unknown }} A merged config object
1401
- */
1402
- mergeConfigs(...configObjects) {
1403
- const formattedConfigObject = {};
1404
-
1405
- // Loop through each of the passed objects
1406
- for (const configObject of configObjects) {
1407
- for (const key of Object.keys(configObject)) {
1408
- const option = formattedConfigObject[key];
1409
- const override = configObject[key];
1410
-
1411
- // Push their keys one-by-one into formattedConfigObject. Any duplicate
1412
- // keys with object values will be merged, otherwise the new value will
1413
- // override the existing value.
1414
- if (typeof option === 'object' && typeof override === 'object') {
1415
- // @ts-expect-error Index signature for type 'string' is missing
1416
- formattedConfigObject[key] = this.mergeConfigs(option, override);
1417
- } else {
1418
- formattedConfigObject[key] = override;
1419
- }
1420
- }
1421
- }
1422
- return formattedConfigObject;
1423
- }
1621
+ * Name for the component used when initialising using data-module attributes.
1622
+ */
1424
1623
  }
1624
+ DatePicker.moduleName = 'moj-date-picker';
1625
+ /**
1626
+ * Date picker default config
1627
+ *
1628
+ * @type {DatePickerConfig}
1629
+ */
1630
+ DatePicker.defaults = Object.freeze({
1631
+ leadingZeros: false,
1632
+ weekStartDay: 'monday',
1633
+ input: {
1634
+ selector: '.moj-js-datepicker-input'
1635
+ }
1636
+ });
1637
+ /**
1638
+ * Date picker config schema
1639
+ *
1640
+ * @satisfies {Schema<DatePickerConfig>}
1641
+ */
1642
+ DatePicker.schema = Object.freeze(/** @type {const} */{
1643
+ properties: {
1644
+ excludedDates: {
1645
+ type: 'string'
1646
+ },
1647
+ excludedDays: {
1648
+ type: 'string'
1649
+ },
1650
+ leadingZeros: {
1651
+ type: 'boolean'
1652
+ },
1653
+ maxDate: {
1654
+ type: 'string'
1655
+ },
1656
+ minDate: {
1657
+ type: 'string'
1658
+ },
1659
+ weekStartDay: {
1660
+ type: 'string'
1661
+ },
1662
+ input: {
1663
+ type: 'object'
1664
+ }
1665
+ }
1666
+ });
1425
1667
  class DSCalendarDay {
1426
1668
  /**
1427
1669
  *
1428
- * @param {HTMLElement} button
1670
+ * @param {HTMLButtonElement} $button
1429
1671
  * @param {number} index
1430
1672
  * @param {number} row
1431
1673
  * @param {number} column
1432
1674
  * @param {DatePicker} picker
1433
1675
  */
1434
- constructor(button, index, row, column, picker) {
1676
+ constructor($button, index, row, column, picker) {
1435
1677
  this.index = index;
1436
1678
  this.row = row;
1437
1679
  this.column = column;
1438
- this.button = button;
1680
+ this.$button = $button;
1439
1681
  this.picker = picker;
1440
1682
  this.date = new Date();
1441
- this.button.addEventListener('keydown', this.keyPress.bind(this));
1442
- this.button.addEventListener('click', this.click.bind(this));
1683
+ this.$button.addEventListener('keydown', this.keyPress.bind(this));
1684
+ this.$button.addEventListener('click', this.click.bind(this));
1443
1685
  }
1444
1686
 
1445
1687
  /**
@@ -1451,26 +1693,34 @@ class DSCalendarDay {
1451
1693
  const label = day.getDate();
1452
1694
  let accessibleLabel = this.picker.formattedDateHuman(day);
1453
1695
  if (disabled) {
1454
- this.button.setAttribute('aria-disabled', 'true');
1696
+ this.$button.setAttribute('aria-disabled', 'true');
1455
1697
  accessibleLabel = `Excluded date, ${accessibleLabel}`;
1456
1698
  } else {
1457
- this.button.removeAttribute('aria-disabled');
1699
+ this.$button.removeAttribute('aria-disabled');
1458
1700
  }
1459
1701
  if (hidden) {
1460
- this.button.style.display = 'none';
1702
+ this.$button.style.display = 'none';
1461
1703
  } else {
1462
- this.button.style.display = 'block';
1704
+ this.$button.style.display = 'block';
1463
1705
  }
1464
- this.button.setAttribute('data-testid', this.picker.formattedDateFromDate(day));
1465
- this.button.innerHTML = `<span class="govuk-visually-hidden">${accessibleLabel}</span><span aria-hidden="true">${label}</span>`;
1706
+ this.$button.setAttribute('data-testid', this.picker.formattedDateFromDate(day));
1707
+ this.$button.innerHTML = `<span class="govuk-visually-hidden">${accessibleLabel}</span><span aria-hidden="true">${label}</span>`;
1466
1708
  this.date = new Date(day);
1467
1709
  }
1710
+
1711
+ /**
1712
+ * @param {MouseEvent} event - Click event
1713
+ */
1468
1714
  click(event) {
1469
1715
  this.picker.goToDate(this.date);
1470
1716
  this.picker.selectDate(this.date);
1471
1717
  event.stopPropagation();
1472
1718
  event.preventDefault();
1473
1719
  }
1720
+
1721
+ /**
1722
+ * @param {KeyboardEvent} event - Keydown event
1723
+ */
1474
1724
  keyPress(event) {
1475
1725
  let calendarNavKey = true;
1476
1726
  switch (event.key) {
@@ -1527,45 +1777,61 @@ class DSCalendarDay {
1527
1777
  * @typedef {object} DatePickerConfig
1528
1778
  * @property {string} [excludedDates] - Dates that cannot be selected
1529
1779
  * @property {string} [excludedDays] - Days that cannot be selected
1530
- * @property {boolean} [leadingZeroes] - Whether to add leading zeroes when populating the field
1780
+ * @property {boolean} [leadingZeros] - Whether to add leading zeroes when populating the field
1531
1781
  * @property {string} [minDate] - The earliest available date
1532
1782
  * @property {string} [maxDate] - The latest available date
1533
1783
  * @property {string} [weekStartDay] - First day of the week in calendar view
1784
+ * @property {object} [input] - Input config
1785
+ * @property {string} [input.selector] - Selector for the input element
1786
+ * @property {Element | null} [input.element] - HTML element for the input
1534
1787
  */
1535
1788
 
1536
1789
  /**
1537
- * @import { Schema } from '../../all.mjs'
1790
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
1538
1791
  */
1539
1792
 
1540
- class FilterToggleButton {
1541
- constructor(options) {
1542
- this.options = options;
1543
- this.container = this.options.toggleButton.container;
1544
- this.filterContainer = this.options.filter.container;
1793
+ /**
1794
+ * @augments {ConfigurableComponent<FilterToggleButtonConfig>}
1795
+ */
1796
+ class FilterToggleButton extends ConfigurableComponent {
1797
+ /**
1798
+ * @param {Element | null} $root - HTML element to use for filter toggle button
1799
+ * @param {FilterToggleButtonConfig} [config] - Filter toggle button config
1800
+ */
1801
+ constructor($root, config = {}) {
1802
+ var _this$config$toggleBu, _this$config$closeBut;
1803
+ super($root, config);
1804
+ const $toggleButtonContainer = (_this$config$toggleBu = this.config.toggleButtonContainer.element) != null ? _this$config$toggleBu : document.querySelector(this.config.toggleButtonContainer.selector);
1805
+ const $closeButtonContainer = (_this$config$closeBut = this.config.closeButtonContainer.element) != null ? _this$config$closeBut : this.$root.querySelector(this.config.closeButtonContainer.selector);
1806
+ if (!($toggleButtonContainer instanceof HTMLElement && $closeButtonContainer instanceof HTMLElement)) {
1807
+ return this;
1808
+ }
1809
+ this.$toggleButtonContainer = $toggleButtonContainer;
1810
+ this.$closeButtonContainer = $closeButtonContainer;
1545
1811
  this.createToggleButton();
1546
1812
  this.setupResponsiveChecks();
1547
- this.filterContainer.setAttribute('tabindex', '-1');
1548
- if (this.options.startHidden) {
1813
+ this.$root.setAttribute('tabindex', '-1');
1814
+ if (this.config.startHidden) {
1549
1815
  this.hideMenu();
1550
1816
  }
1551
1817
  }
1552
1818
  setupResponsiveChecks() {
1553
- this.mq = window.matchMedia(this.options.bigModeMediaQuery);
1819
+ this.mq = window.matchMedia(this.config.bigModeMediaQuery);
1554
1820
  this.mq.addListener(this.checkMode.bind(this));
1555
- this.checkMode(this.mq);
1821
+ this.checkMode();
1556
1822
  }
1557
1823
  createToggleButton() {
1558
- this.menuButton = document.createElement('button');
1559
- this.menuButton.setAttribute('type', 'button');
1560
- this.menuButton.setAttribute('aria-haspopup', 'true');
1561
- this.menuButton.setAttribute('aria-expanded', 'false');
1562
- this.menuButton.className = `govuk-button ${this.options.toggleButton.classes}`;
1563
- this.menuButton.textContent = this.options.toggleButton.showText;
1564
- this.menuButton.addEventListener('click', this.onMenuButtonClick.bind(this));
1565
- this.container.append(this.menuButton);
1566
- }
1567
- checkMode(mq) {
1568
- if (mq.matches) {
1824
+ this.$menuButton = document.createElement('button');
1825
+ this.$menuButton.setAttribute('type', 'button');
1826
+ this.$menuButton.setAttribute('aria-haspopup', 'true');
1827
+ this.$menuButton.setAttribute('aria-expanded', 'false');
1828
+ this.$menuButton.className = `govuk-button ${this.config.toggleButton.classes}`;
1829
+ this.$menuButton.textContent = this.config.toggleButton.showText;
1830
+ this.$menuButton.addEventListener('click', this.onMenuButtonClick.bind(this));
1831
+ this.$toggleButtonContainer.append(this.$menuButton);
1832
+ }
1833
+ checkMode() {
1834
+ if (this.mq.matches) {
1569
1835
  this.enableBigMode();
1570
1836
  } else {
1571
1837
  this.enableSmallMode();
@@ -1580,69 +1846,147 @@ class FilterToggleButton {
1580
1846
  this.addCloseButton();
1581
1847
  }
1582
1848
  addCloseButton() {
1583
- if (!this.options.closeButton) {
1584
- return;
1585
- }
1586
- this.closeButton = document.createElement('button');
1587
- this.closeButton.setAttribute('type', 'button');
1588
- this.closeButton.className = 'moj-filter__close';
1589
- this.closeButton.textContent = this.options.closeButton.text;
1590
- this.closeButton.addEventListener('click', this.onCloseClick.bind(this));
1591
- this.options.closeButton.container.append(this.closeButton);
1849
+ this.$closeButton = document.createElement('button');
1850
+ this.$closeButton.setAttribute('type', 'button');
1851
+ this.$closeButton.className = this.config.closeButton.classes;
1852
+ this.$closeButton.textContent = this.config.closeButton.text;
1853
+ this.$closeButton.addEventListener('click', this.onCloseClick.bind(this));
1854
+ this.$closeButtonContainer.append(this.$closeButton);
1592
1855
  }
1593
1856
  onCloseClick() {
1594
1857
  this.hideMenu();
1595
- this.menuButton.focus();
1858
+ this.$menuButton.focus();
1596
1859
  }
1597
1860
  removeCloseButton() {
1598
- if (this.closeButton) {
1599
- this.closeButton.remove();
1600
- this.closeButton = null;
1861
+ if (this.$closeButton) {
1862
+ this.$closeButton.remove();
1863
+ this.$closeButton = null;
1601
1864
  }
1602
1865
  }
1603
1866
  hideMenu() {
1604
- this.menuButton.setAttribute('aria-expanded', 'false');
1605
- this.filterContainer.classList.add('moj-js-hidden');
1606
- this.menuButton.textContent = this.options.toggleButton.showText;
1867
+ this.$menuButton.setAttribute('aria-expanded', 'false');
1868
+ this.$root.classList.add('moj-js-hidden');
1869
+ this.$menuButton.textContent = this.config.toggleButton.showText;
1607
1870
  }
1608
1871
  showMenu() {
1609
- this.menuButton.setAttribute('aria-expanded', 'true');
1610
- this.filterContainer.classList.remove('moj-js-hidden');
1611
- this.menuButton.textContent = this.options.toggleButton.hideText;
1872
+ this.$menuButton.setAttribute('aria-expanded', 'true');
1873
+ this.$root.classList.remove('moj-js-hidden');
1874
+ this.$menuButton.textContent = this.config.toggleButton.hideText;
1612
1875
  }
1613
1876
  onMenuButtonClick() {
1614
1877
  this.toggle();
1615
1878
  }
1616
1879
  toggle() {
1617
- if (this.menuButton.getAttribute('aria-expanded') === 'false') {
1880
+ if (this.$menuButton.getAttribute('aria-expanded') === 'false') {
1618
1881
  this.showMenu();
1619
- this.filterContainer.focus();
1882
+ this.$root.focus();
1620
1883
  } else {
1621
1884
  this.hideMenu();
1622
1885
  }
1623
1886
  }
1887
+
1888
+ /**
1889
+ * Name for the component used when initialising using data-module attributes.
1890
+ */
1624
1891
  }
1625
1892
 
1626
- class FormValidator {
1893
+ /**
1894
+ * @typedef {object} FilterToggleButtonConfig
1895
+ * @property {string} [bigModeMediaQuery] - Media query for big mode
1896
+ * @property {boolean} [startHidden] - Whether to start hidden
1897
+ * @property {object} [toggleButton] - Toggle button config
1898
+ * @property {string} [toggleButton.showText] - Text for show button
1899
+ * @property {string} [toggleButton.hideText] - Text for hide button
1900
+ * @property {string} [toggleButton.classes] - Classes for toggle button
1901
+ * @property {object} [toggleButtonContainer] - Toggle button container config
1902
+ * @property {string} [toggleButtonContainer.selector] - Selector for toggle button container
1903
+ * @property {Element | null} [toggleButtonContainer.element] - HTML element for toggle button container
1904
+ * @property {object} [closeButton] - Close button config
1905
+ * @property {string} [closeButton.text] - Text for close button
1906
+ * @property {string} [closeButton.classes] - Classes for close button
1907
+ * @property {object} [closeButtonContainer] - Close button container config
1908
+ * @property {string} [closeButtonContainer.selector] - Selector for close button container
1909
+ * @property {Element | null} [closeButtonContainer.element] - HTML element for close button container
1910
+ */
1911
+
1912
+ /**
1913
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
1914
+ */
1915
+ FilterToggleButton.moduleName = 'moj-filter';
1916
+ /**
1917
+ * Filter toggle button config
1918
+ *
1919
+ * @type {FilterToggleButtonConfig}
1920
+ */
1921
+ FilterToggleButton.defaults = Object.freeze({
1922
+ bigModeMediaQuery: '(min-width: 48.0625em)',
1923
+ startHidden: true,
1924
+ toggleButton: {
1925
+ showText: 'Show filter',
1926
+ hideText: 'Hide filter',
1927
+ classes: 'govuk-button--secondary'
1928
+ },
1929
+ toggleButtonContainer: {
1930
+ selector: '.moj-action-bar__filter'
1931
+ },
1932
+ closeButton: {
1933
+ text: 'Close',
1934
+ classes: 'moj-filter__close'
1935
+ },
1936
+ closeButtonContainer: {
1937
+ selector: '.moj-filter__header-action'
1938
+ }
1939
+ });
1940
+ /**
1941
+ * Filter toggle button config schema
1942
+ *
1943
+ * @satisfies {Schema<FilterToggleButtonConfig>}
1944
+ */
1945
+ FilterToggleButton.schema = Object.freeze(/** @type {const} */{
1946
+ properties: {
1947
+ bigModeMediaQuery: {
1948
+ type: 'string'
1949
+ },
1950
+ startHidden: {
1951
+ type: 'boolean'
1952
+ },
1953
+ toggleButton: {
1954
+ type: 'object'
1955
+ },
1956
+ toggleButtonContainer: {
1957
+ type: 'object'
1958
+ },
1959
+ closeButton: {
1960
+ type: 'object'
1961
+ },
1962
+ closeButtonContainer: {
1963
+ type: 'object'
1964
+ }
1965
+ }
1966
+ });
1967
+
1968
+ /**
1969
+ * @augments {ConfigurableComponent<FormValidatorConfig, HTMLFormElement>}
1970
+ */
1971
+ class FormValidator extends ConfigurableComponent {
1627
1972
  /**
1628
- * @param {Element | null} form - HTML element to use for form validator
1629
- * @param {FormValidatorConfig} [config] - Button menu config
1973
+ * @param {Element | null} $root - HTML element to use for form validator
1974
+ * @param {FormValidatorConfig} [config] - Form validator config
1630
1975
  */
1631
- constructor(form, config = {}) {
1632
- if (!form || !(form instanceof HTMLFormElement)) {
1976
+ constructor($root, config = {}) {
1977
+ super($root, config);
1978
+ const $summary = this.config.summary.element || document.querySelector(this.config.summary.selector);
1979
+ if (!$summary || !($summary instanceof HTMLElement)) {
1633
1980
  return this;
1634
1981
  }
1635
- this.form = form;
1636
- this.errors = [];
1637
- this.validators = [];
1638
- this.form.addEventListener('submit', this.onSubmit.bind(this));
1639
- this.summary = config.summary || document.querySelector('.govuk-error-summary');
1982
+ this.$summary = $summary;
1983
+ this.errors = /** @type {ValidationError[]} */[];
1984
+ this.validators = /** @type {Validator[]} */[];
1640
1985
  this.originalTitle = document.title;
1986
+ this.$root.addEventListener('submit', this.onSubmit.bind(this));
1641
1987
  }
1642
- escapeHtml(string) {
1643
- return String(string).replace(/[&<>"'`=/]/g, function fromEntityMap(s) {
1644
- return FormValidator.entityMap[s];
1645
- });
1988
+ escapeHtml(string = '') {
1989
+ return String(string).replace(/[&<>"'`=/]/g, name => FormValidator.entityMap[name]);
1646
1990
  }
1647
1991
  resetTitle() {
1648
1992
  document.title = this.originalTitle;
@@ -1651,10 +1995,10 @@ class FormValidator {
1651
1995
  document.title = `${this.errors.length} errors - ${document.title}`;
1652
1996
  }
1653
1997
  showSummary() {
1654
- this.summary.innerHTML = this.getSummaryHtml();
1655
- this.summary.classList.remove('moj-hidden');
1656
- this.summary.setAttribute('aria-labelledby', 'errorSummary-heading');
1657
- this.summary.focus();
1998
+ this.$summary.innerHTML = this.getSummaryHtml();
1999
+ this.$summary.classList.remove('moj-hidden');
2000
+ this.$summary.setAttribute('aria-labelledby', 'errorSummary-heading');
2001
+ this.$summary.focus();
1658
2002
  }
1659
2003
  getSummaryHtml() {
1660
2004
  let html = '<h2 id="error-summary-title" class="govuk-error-summary__title">There is a problem</h2>';
@@ -1672,9 +2016,13 @@ class FormValidator {
1672
2016
  return html;
1673
2017
  }
1674
2018
  hideSummary() {
1675
- this.summary.classList.add('moj-hidden');
1676
- this.summary.removeAttribute('aria-labelledby');
2019
+ this.$summary.classList.add('moj-hidden');
2020
+ this.$summary.removeAttribute('aria-labelledby');
1677
2021
  }
2022
+
2023
+ /**
2024
+ * @param {SubmitEvent} event - Form submit event
2025
+ */
1678
2026
  onSubmit(event) {
1679
2027
  this.removeInlineErrors();
1680
2028
  this.hideSummary();
@@ -1691,25 +2039,29 @@ class FormValidator {
1691
2039
  this.showInlineError(error);
1692
2040
  }
1693
2041
  }
2042
+
2043
+ /**
2044
+ * @param {ValidationError} error
2045
+ */
1694
2046
  showInlineError(error) {
1695
- const errorSpan = document.createElement('span');
1696
- errorSpan.id = `${error.fieldName}-error`;
1697
- errorSpan.classList.add('govuk-error-message');
1698
- errorSpan.innerHTML = this.escapeHtml(error.message);
1699
- const control = document.querySelector(`#${error.fieldName}`);
1700
- const fieldset = control.closest('.govuk-fieldset');
1701
- const fieldContainer = (fieldset || control).closest('.govuk-form-group');
1702
- const label = fieldContainer.querySelector('label');
1703
- const legend = fieldContainer.querySelector('legend');
1704
- fieldContainer.classList.add('govuk-form-group--error');
1705
- if (fieldset && legend) {
1706
- legend.after(errorSpan);
1707
- fieldContainer.setAttribute('aria-invalid', 'true');
1708
- addAttributeValue(fieldset, 'aria-describedby', errorSpan.id);
1709
- } else if (label && control) {
1710
- label.after(errorSpan);
1711
- control.setAttribute('aria-invalid', 'true');
1712
- addAttributeValue(control, 'aria-describedby', errorSpan.id);
2047
+ const $errorSpan = document.createElement('span');
2048
+ $errorSpan.id = `${error.fieldName}-error`;
2049
+ $errorSpan.classList.add('govuk-error-message');
2050
+ $errorSpan.innerHTML = this.escapeHtml(error.message);
2051
+ const $control = document.querySelector(`#${error.fieldName}`);
2052
+ const $fieldset = $control.closest('.govuk-fieldset');
2053
+ const $fieldContainer = ($fieldset || $control).closest('.govuk-form-group');
2054
+ const $label = $fieldContainer.querySelector('label');
2055
+ const $legend = $fieldContainer.querySelector('legend');
2056
+ $fieldContainer.classList.add('govuk-form-group--error');
2057
+ if ($fieldset && $legend) {
2058
+ $legend.after($errorSpan);
2059
+ $fieldContainer.setAttribute('aria-invalid', 'true');
2060
+ addAttributeValue($fieldset, 'aria-describedby', $errorSpan.id);
2061
+ } else if ($label && $control) {
2062
+ $label.after($errorSpan);
2063
+ $control.setAttribute('aria-invalid', 'true');
2064
+ addAttributeValue($control, 'aria-describedby', $errorSpan.id);
1713
2065
  }
1714
2066
  }
1715
2067
  removeInlineErrors() {
@@ -1717,33 +2069,46 @@ class FormValidator {
1717
2069
  this.removeInlineError(error);
1718
2070
  }
1719
2071
  }
2072
+
2073
+ /**
2074
+ * @param {ValidationError} error
2075
+ */
1720
2076
  removeInlineError(error) {
1721
- const errorSpan = document.querySelector(`#${error.fieldName}-error`);
1722
- const control = document.querySelector(`#${error.fieldName}`);
1723
- const fieldset = control.closest('.govuk-fieldset');
1724
- const fieldContainer = (fieldset || control).closest('.govuk-form-group');
1725
- const label = fieldContainer.querySelector('label');
1726
- const legend = fieldContainer.querySelector('legend');
1727
- errorSpan.remove();
1728
- fieldContainer.classList.remove('govuk-form-group--error');
1729
- if (fieldset && legend) {
1730
- fieldContainer.removeAttribute('aria-invalid');
1731
- removeAttributeValue(fieldset, 'aria-describedby', errorSpan.id);
1732
- } else if (label && control) {
1733
- control.removeAttribute('aria-invalid');
1734
- removeAttributeValue(control, 'aria-describedby', errorSpan.id);
2077
+ const $errorSpan = document.querySelector(`#${error.fieldName}-error`);
2078
+ const $control = document.querySelector(`#${error.fieldName}`);
2079
+ const $fieldset = $control.closest('.govuk-fieldset');
2080
+ const $fieldContainer = ($fieldset || $control).closest('.govuk-form-group');
2081
+ const $label = $fieldContainer.querySelector('label');
2082
+ const $legend = $fieldContainer.querySelector('legend');
2083
+ $errorSpan.remove();
2084
+ $fieldContainer.classList.remove('govuk-form-group--error');
2085
+ if ($fieldset && $legend) {
2086
+ $fieldContainer.removeAttribute('aria-invalid');
2087
+ removeAttributeValue($fieldset, 'aria-describedby', $errorSpan.id);
2088
+ } else if ($label && $control) {
2089
+ $control.removeAttribute('aria-invalid');
2090
+ removeAttributeValue($control, 'aria-describedby', $errorSpan.id);
1735
2091
  }
1736
2092
  }
2093
+
2094
+ /**
2095
+ * @param {string} fieldName - Field name
2096
+ * @param {ValidationRule[]} rules - Validation rules
2097
+ */
1737
2098
  addValidator(fieldName, rules) {
1738
2099
  this.validators.push({
1739
2100
  fieldName,
1740
2101
  rules,
1741
- field: this.form.elements[fieldName]
2102
+ field: this.$root.elements.namedItem(fieldName)
1742
2103
  });
1743
2104
  }
1744
2105
  validate() {
1745
2106
  this.errors = [];
2107
+
2108
+ /** @type {Validator | null} */
1746
2109
  let validator = null;
2110
+
2111
+ /** @type {boolean | string} */
1747
2112
  let validatorReturnValue = true;
1748
2113
  let i;
1749
2114
  let j;
@@ -1768,11 +2133,41 @@ class FormValidator {
1768
2133
  }
1769
2134
  return this.errors.length === 0;
1770
2135
  }
2136
+
2137
+ /**
2138
+ * @type {Record<string, string>}
2139
+ */
1771
2140
  }
1772
2141
 
1773
2142
  /**
1774
2143
  * @typedef {object} FormValidatorConfig
1775
- * @property {HTMLElement} [summary] - HTML element to use for error summary
2144
+ * @property {object} [summary] - Error summary config
2145
+ * @property {string} [summary.selector] - Selector for error summary
2146
+ * @property {Element | null} [summary.element] - HTML element for error summary
2147
+ */
2148
+
2149
+ /**
2150
+ * @typedef {object} ValidationRule
2151
+ * @property {(field: Validator['field'], params: Record<string, Validator['field']>) => boolean | string} method - Validation method
2152
+ * @property {string} message - Error message
2153
+ * @property {Record<string, Validator['field']>} [params] - Parameters for validation
2154
+ */
2155
+
2156
+ /**
2157
+ * @typedef {object} ValidationError
2158
+ * @property {string} fieldName - Name of the field
2159
+ * @property {string} message - Validation error message
2160
+ */
2161
+
2162
+ /**
2163
+ * @typedef {object} Validator
2164
+ * @property {string} fieldName - Name of the field
2165
+ * @property {ValidationRule[]} rules - Validation rules
2166
+ * @property {Element | RadioNodeList} field - Form field
2167
+ */
2168
+
2169
+ /**
2170
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
1776
2171
  */
1777
2172
  FormValidator.entityMap = {
1778
2173
  '&': '&amp;',
@@ -1784,164 +2179,218 @@ FormValidator.entityMap = {
1784
2179
  '`': '&#x60;',
1785
2180
  '=': '&#x3D;'
1786
2181
  };
2182
+ /**
2183
+ * Name for the component used when initialising using data-module attributes.
2184
+ */
2185
+ FormValidator.moduleName = 'moj-form-validator';
2186
+ /**
2187
+ * Multi file upload default config
2188
+ *
2189
+ * @type {FormValidatorConfig}
2190
+ */
2191
+ FormValidator.defaults = Object.freeze({
2192
+ summary: {
2193
+ selector: '.govuk-error-summary'
2194
+ }
2195
+ });
2196
+ /**
2197
+ * Multi file upload config schema
2198
+ *
2199
+ * @satisfies {Schema<FormValidatorConfig>}
2200
+ */
2201
+ FormValidator.schema = Object.freeze(/** @type {const} */{
2202
+ properties: {
2203
+ summary: {
2204
+ type: 'object'
2205
+ }
2206
+ }
2207
+ });
1787
2208
 
1788
2209
  /* eslint-disable @typescript-eslint/no-empty-function */
1789
2210
 
1790
- class MultiFileUpload {
2211
+
2212
+ /**
2213
+ * @augments {ConfigurableComponent<MultiFileUploadConfig>}
2214
+ */
2215
+ class MultiFileUpload extends ConfigurableComponent {
1791
2216
  /**
1792
- * @param {MultiFileUploadConfig} [params] - Multi file upload config
2217
+ * @param {Element | null} $root - HTML element to use for multi file upload
2218
+ * @param {MultiFileUploadConfig} [config] - Multi file upload config
1793
2219
  */
1794
- constructor(params = {}) {
1795
- const {
1796
- container
1797
- } = params;
1798
- if (!container || !(container instanceof HTMLElement) || !(dragAndDropSupported() && formDataSupported() && fileApiSupported())) {
2220
+ constructor($root, config = {}) {
2221
+ var _this$config$feedback;
2222
+ super($root, config);
2223
+ if (!MultiFileUpload.isSupported()) {
1799
2224
  return this;
1800
2225
  }
1801
- this.container = container;
1802
- this.container.classList.add('moj-multi-file-upload--enhanced');
1803
- this.defaultParams = {
1804
- uploadFileEntryHook: () => {},
1805
- uploadFileExitHook: () => {},
1806
- uploadFileErrorHook: () => {},
1807
- fileDeleteHook: () => {},
1808
- uploadStatusText: 'Uploading files, please wait',
1809
- dropzoneHintText: 'Drag and drop files here or',
1810
- dropzoneButtonText: 'Choose files'
1811
- };
1812
- this.params = Object.assign({}, this.defaultParams, params);
1813
- this.feedbackContainer = /** @type {HTMLDivElement} */
1814
- this.container.querySelector('.moj-multi-file__uploaded-files');
2226
+ const $feedbackContainer = (_this$config$feedback = this.config.feedbackContainer.element) != null ? _this$config$feedback : this.$root.querySelector(this.config.feedbackContainer.selector);
2227
+ if (!$feedbackContainer || !($feedbackContainer instanceof HTMLElement)) {
2228
+ return this;
2229
+ }
2230
+ this.$feedbackContainer = $feedbackContainer;
1815
2231
  this.setupFileInput();
1816
2232
  this.setupDropzone();
1817
2233
  this.setupLabel();
1818
2234
  this.setupStatusBox();
1819
- this.container.addEventListener('click', this.onFileDeleteClick.bind(this));
2235
+ this.$root.addEventListener('click', this.onFileDeleteClick.bind(this));
2236
+ this.$root.classList.add('moj-multi-file-upload--enhanced');
1820
2237
  }
1821
2238
  setupDropzone() {
1822
- this.dropzone = document.createElement('div');
1823
- this.dropzone.classList.add('moj-multi-file-upload__dropzone');
1824
- this.dropzone.addEventListener('dragover', this.onDragOver.bind(this));
1825
- this.dropzone.addEventListener('dragleave', this.onDragLeave.bind(this));
1826
- this.dropzone.addEventListener('drop', this.onDrop.bind(this));
1827
- this.fileInput.replaceWith(this.dropzone);
1828
- this.dropzone.appendChild(this.fileInput);
2239
+ this.$dropzone = document.createElement('div');
2240
+ this.$dropzone.classList.add('moj-multi-file-upload__dropzone');
2241
+ this.$dropzone.addEventListener('dragover', this.onDragOver.bind(this));
2242
+ this.$dropzone.addEventListener('dragleave', this.onDragLeave.bind(this));
2243
+ this.$dropzone.addEventListener('drop', this.onDrop.bind(this));
2244
+ this.$fileInput.replaceWith(this.$dropzone);
2245
+ this.$dropzone.appendChild(this.$fileInput);
1829
2246
  }
1830
2247
  setupLabel() {
1831
- const label = document.createElement('label');
1832
- label.setAttribute('for', this.fileInput.id);
1833
- label.classList.add('govuk-button', 'govuk-button--secondary');
1834
- label.textContent = this.params.dropzoneButtonText;
1835
- const hint = document.createElement('p');
1836
- hint.classList.add('govuk-body');
1837
- hint.textContent = this.params.dropzoneHintText;
1838
- this.label = label;
1839
- this.dropzone.append(hint);
1840
- this.dropzone.append(label);
2248
+ const $label = document.createElement('label');
2249
+ $label.setAttribute('for', this.$fileInput.id);
2250
+ $label.classList.add('govuk-button', 'govuk-button--secondary');
2251
+ $label.textContent = this.config.dropzoneButtonText;
2252
+ const $hint = document.createElement('p');
2253
+ $hint.classList.add('govuk-body');
2254
+ $hint.textContent = this.config.dropzoneHintText;
2255
+ this.$label = $label;
2256
+ this.$dropzone.append($hint);
2257
+ this.$dropzone.append($label);
1841
2258
  }
1842
2259
  setupFileInput() {
1843
- this.fileInput = /** @type {HTMLInputElement} */
1844
- this.container.querySelector('.moj-multi-file-upload__input');
1845
- this.fileInput.addEventListener('change', this.onFileChange.bind(this));
1846
- this.fileInput.addEventListener('focus', this.onFileFocus.bind(this));
1847
- this.fileInput.addEventListener('blur', this.onFileBlur.bind(this));
2260
+ this.$fileInput = /** @type {HTMLInputElement} */
2261
+ this.$root.querySelector('.moj-multi-file-upload__input');
2262
+ this.$fileInput.addEventListener('change', this.onFileChange.bind(this));
2263
+ this.$fileInput.addEventListener('focus', this.onFileFocus.bind(this));
2264
+ this.$fileInput.addEventListener('blur', this.onFileBlur.bind(this));
1848
2265
  }
1849
2266
  setupStatusBox() {
1850
- this.status = document.createElement('div');
1851
- this.status.classList.add('govuk-visually-hidden');
1852
- this.status.setAttribute('aria-live', 'polite');
1853
- this.status.setAttribute('role', 'status');
1854
- this.dropzone.append(this.status);
2267
+ this.$status = document.createElement('div');
2268
+ this.$status.classList.add('govuk-visually-hidden');
2269
+ this.$status.setAttribute('aria-live', 'polite');
2270
+ this.$status.setAttribute('role', 'status');
2271
+ this.$dropzone.append(this.$status);
1855
2272
  }
2273
+
2274
+ /**
2275
+ * @param {DragEvent} event - Drag event
2276
+ */
1856
2277
  onDragOver(event) {
1857
2278
  event.preventDefault();
1858
- this.dropzone.classList.add('moj-multi-file-upload--dragover');
2279
+ this.$dropzone.classList.add('moj-multi-file-upload--dragover');
1859
2280
  }
1860
2281
  onDragLeave() {
1861
- this.dropzone.classList.remove('moj-multi-file-upload--dragover');
2282
+ this.$dropzone.classList.remove('moj-multi-file-upload--dragover');
1862
2283
  }
2284
+
2285
+ /**
2286
+ * @param {DragEvent} event - Drag event
2287
+ */
1863
2288
  onDrop(event) {
1864
2289
  event.preventDefault();
1865
- this.dropzone.classList.remove('moj-multi-file-upload--dragover');
1866
- this.feedbackContainer.classList.remove('moj-hidden');
1867
- this.status.textContent = this.params.uploadStatusText;
2290
+ this.$dropzone.classList.remove('moj-multi-file-upload--dragover');
2291
+ this.$feedbackContainer.classList.remove('moj-hidden');
2292
+ this.$status.textContent = this.config.uploadStatusText;
1868
2293
  this.uploadFiles(event.dataTransfer.files);
1869
2294
  }
2295
+
2296
+ /**
2297
+ * @param {FileList} files - File list
2298
+ */
1870
2299
  uploadFiles(files) {
1871
2300
  for (const file of Array.from(files)) {
1872
2301
  this.uploadFile(file);
1873
2302
  }
1874
2303
  }
1875
2304
  onFileChange() {
1876
- this.feedbackContainer.classList.remove('moj-hidden');
1877
- this.status.textContent = this.params.uploadStatusText;
1878
- this.uploadFiles(this.fileInput.files);
1879
- const fileInput = this.fileInput.cloneNode(true);
1880
- if (!fileInput || !(fileInput instanceof HTMLInputElement)) {
2305
+ this.$feedbackContainer.classList.remove('moj-hidden');
2306
+ this.$status.textContent = this.config.uploadStatusText;
2307
+ this.uploadFiles(this.$fileInput.files);
2308
+ const $fileInput = this.$fileInput.cloneNode(true);
2309
+ if (!$fileInput || !($fileInput instanceof HTMLInputElement)) {
1881
2310
  return;
1882
2311
  }
1883
- fileInput.value = '';
1884
- this.fileInput.replaceWith(fileInput);
2312
+ $fileInput.value = '';
2313
+ this.$fileInput.replaceWith($fileInput);
1885
2314
  this.setupFileInput();
1886
- this.fileInput.focus();
2315
+ this.$fileInput.focus();
1887
2316
  }
1888
2317
  onFileFocus() {
1889
- this.label.classList.add('moj-multi-file-upload--focused');
2318
+ this.$label.classList.add('moj-multi-file-upload--focused');
1890
2319
  }
1891
2320
  onFileBlur() {
1892
- this.label.classList.remove('moj-multi-file-upload--focused');
2321
+ this.$label.classList.remove('moj-multi-file-upload--focused');
1893
2322
  }
2323
+
2324
+ /**
2325
+ * @param {UploadResponseSuccess['success']} success
2326
+ */
1894
2327
  getSuccessHtml(success) {
1895
2328
  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>`;
1896
2329
  }
2330
+
2331
+ /**
2332
+ * @param {UploadResponseError['error']} error
2333
+ */
1897
2334
  getErrorHtml(error) {
1898
2335
  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>`;
1899
2336
  }
2337
+
2338
+ /**
2339
+ * @param {File} file
2340
+ */
1900
2341
  getFileRow(file) {
1901
- const row = document.createElement('div');
1902
- row.classList.add('govuk-summary-list__row', 'moj-multi-file-upload__row');
1903
- row.innerHTML = `
2342
+ const $row = document.createElement('div');
2343
+ $row.classList.add('govuk-summary-list__row', 'moj-multi-file-upload__row');
2344
+ $row.innerHTML = `
1904
2345
  <div class="govuk-summary-list__value moj-multi-file-upload__message">
1905
2346
  <span class="moj-multi-file-upload__filename">${file.name}</span>
1906
2347
  <span class="moj-multi-file-upload__progress">0%</span>
1907
2348
  </div>
1908
2349
  <div class="govuk-summary-list__actions moj-multi-file-upload__actions"></div>
1909
2350
  `;
1910
- return row;
2351
+ return $row;
1911
2352
  }
2353
+
2354
+ /**
2355
+ * @param {UploadResponseFile} file
2356
+ */
1912
2357
  getDeleteButton(file) {
1913
- const button = document.createElement('button');
1914
- button.setAttribute('type', 'button');
1915
- button.setAttribute('name', 'delete');
1916
- button.setAttribute('value', file.filename);
1917
- button.classList.add('moj-multi-file-upload__delete', 'govuk-button', 'govuk-button--secondary', 'govuk-!-margin-bottom-0');
1918
- button.innerHTML = `Delete <span class="govuk-visually-hidden">${file.originalname}</span>`;
1919
- return button;
2358
+ const $button = document.createElement('button');
2359
+ $button.setAttribute('type', 'button');
2360
+ $button.setAttribute('name', 'delete');
2361
+ $button.setAttribute('value', file.filename);
2362
+ $button.classList.add('moj-multi-file-upload__delete', 'govuk-button', 'govuk-button--secondary', 'govuk-!-margin-bottom-0');
2363
+ $button.innerHTML = `Delete <span class="govuk-visually-hidden">${file.originalname}</span>`;
2364
+ return $button;
1920
2365
  }
2366
+
2367
+ /**
2368
+ * @param {File} file
2369
+ */
1921
2370
  uploadFile(file) {
1922
- this.params.uploadFileEntryHook(this, file);
1923
- const item = this.getFileRow(file);
1924
- const message = item.querySelector('.moj-multi-file-upload__message');
1925
- const actions = item.querySelector('.moj-multi-file-upload__actions');
1926
- const progress = item.querySelector('.moj-multi-file-upload__progress');
2371
+ this.config.hooks.entryHook(this, file);
2372
+ const $item = this.getFileRow(file);
2373
+ const $message = $item.querySelector('.moj-multi-file-upload__message');
2374
+ const $actions = $item.querySelector('.moj-multi-file-upload__actions');
2375
+ const $progress = $item.querySelector('.moj-multi-file-upload__progress');
1927
2376
  const formData = new FormData();
1928
2377
  formData.append('documents', file);
1929
- this.feedbackContainer.querySelector('.moj-multi-file-upload__list').append(item);
2378
+ this.$feedbackContainer.querySelector('.moj-multi-file-upload__list').append($item);
1930
2379
  const xhr = new XMLHttpRequest();
1931
2380
  const onLoad = () => {
1932
2381
  if (xhr.status < 200 || xhr.status >= 300 || !('success' in xhr.response)) {
1933
2382
  return onError();
1934
2383
  }
1935
- message.innerHTML = this.getSuccessHtml(xhr.response.success);
1936
- this.status.textContent = xhr.response.success.messageText;
1937
- actions.append(this.getDeleteButton(xhr.response.file));
1938
- this.params.uploadFileExitHook(this, file, xhr, xhr.responseText);
2384
+ $message.innerHTML = this.getSuccessHtml(xhr.response.success);
2385
+ this.$status.textContent = xhr.response.success.messageText;
2386
+ $actions.append(this.getDeleteButton(xhr.response.file));
2387
+ this.config.hooks.exitHook(this, file, xhr, xhr.responseText);
1939
2388
  };
1940
2389
  const onError = () => {
1941
2390
  const error = new Error(xhr.response && 'error' in xhr.response ? xhr.response.error.message : xhr.statusText || 'Upload failed');
1942
- message.innerHTML = this.getErrorHtml(error);
1943
- this.status.textContent = error.message;
1944
- this.params.uploadFileErrorHook(this, file, xhr, xhr.responseText, error);
2391
+ $message.innerHTML = this.getErrorHtml(error);
2392
+ this.$status.textContent = error.message;
2393
+ this.config.hooks.errorHook(this, file, xhr, xhr.responseText, error);
1945
2394
  };
1946
2395
  xhr.addEventListener('load', onLoad);
1947
2396
  xhr.addEventListener('error', onError);
@@ -1950,15 +2399,19 @@ class MultiFileUpload {
1950
2399
  return;
1951
2400
  }
1952
2401
  const percentComplete = Math.round(event.loaded / event.total * 100);
1953
- progress.textContent = ` ${percentComplete}%`;
2402
+ $progress.textContent = ` ${percentComplete}%`;
1954
2403
  });
1955
- xhr.open('POST', this.params.uploadUrl);
2404
+ xhr.open('POST', this.config.uploadUrl);
1956
2405
  xhr.responseType = 'json';
1957
2406
  xhr.send(formData);
1958
2407
  }
2408
+
2409
+ /**
2410
+ * @param {MouseEvent} event - Click event
2411
+ */
1959
2412
  onFileDeleteClick(event) {
1960
- const button = event.target;
1961
- if (!button || !(button instanceof HTMLButtonElement) || !button.classList.contains('moj-multi-file-upload__delete')) {
2413
+ const $button = event.target;
2414
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-multi-file-upload__delete')) {
1962
2415
  return;
1963
2416
  }
1964
2417
  event.preventDefault(); // if user refreshes page and then deletes
@@ -1968,155 +2421,371 @@ class MultiFileUpload {
1968
2421
  if (xhr.status < 200 || xhr.status >= 300) {
1969
2422
  return;
1970
2423
  }
1971
- const rows = Array.from(this.feedbackContainer.querySelectorAll('.moj-multi-file-upload__row'));
1972
- if (rows.length === 1) {
1973
- this.feedbackContainer.classList.add('moj-hidden');
2424
+ const $rows = Array.from(this.$feedbackContainer.querySelectorAll('.moj-multi-file-upload__row'));
2425
+ if ($rows.length === 1) {
2426
+ this.$feedbackContainer.classList.add('moj-hidden');
1974
2427
  }
1975
- const row = rows.find(row => row.contains(button));
1976
- if (row) row.remove();
1977
- this.params.fileDeleteHook(this, undefined, xhr, xhr.responseText);
2428
+ const $rowDelete = $rows.find($row => $row.contains($button));
2429
+ if ($rowDelete) $rowDelete.remove();
2430
+ this.config.hooks.deleteHook(this, undefined, xhr, xhr.responseText);
1978
2431
  });
1979
- xhr.open('POST', this.params.deleteUrl);
2432
+ xhr.open('POST', this.config.deleteUrl);
1980
2433
  xhr.setRequestHeader('Content-Type', 'application/json');
1981
2434
  xhr.responseType = 'json';
1982
2435
  xhr.send(JSON.stringify({
1983
- [button.name]: button.value
2436
+ [$button.name]: $button.value
1984
2437
  }));
1985
2438
  }
2439
+ static isSupported() {
2440
+ return this.isDragAndDropSupported() && this.isFormDataSupported() && this.isFileApiSupported();
2441
+ }
2442
+ static isDragAndDropSupported() {
2443
+ const div = document.createElement('div');
2444
+ return typeof div.ondrop !== 'undefined';
2445
+ }
2446
+ static isFormDataSupported() {
2447
+ return typeof FormData === 'function';
2448
+ }
2449
+ static isFileApiSupported() {
2450
+ const input = document.createElement('input');
2451
+ input.type = 'file';
2452
+ return typeof input.files !== 'undefined';
2453
+ }
2454
+
2455
+ /**
2456
+ * Name for the component used when initialising using data-module attributes.
2457
+ */
1986
2458
  }
1987
2459
 
1988
- class MultiSelect {
1989
- constructor(options) {
1990
- this.container = options.container;
1991
- if (this.container.hasAttribute('data-moj-multi-select-init')) {
2460
+ /**
2461
+ * Multi file upload config
2462
+ *
2463
+ * @typedef {object} MultiFileUploadConfig
2464
+ * @property {string} [uploadUrl] - File upload URL
2465
+ * @property {string} [deleteUrl] - File delete URL
2466
+ * @property {string} [uploadStatusText] - Upload status text
2467
+ * @property {string} [dropzoneHintText] - Dropzone hint text
2468
+ * @property {string} [dropzoneButtonText] - Dropzone button text
2469
+ * @property {object} [feedbackContainer] - Feedback container config
2470
+ * @property {string} [feedbackContainer.selector] - Selector for feedback container
2471
+ * @property {Element | null} [feedbackContainer.element] - HTML element for feedback container
2472
+ * @property {MultiFileUploadHooks} [hooks] - Upload hooks
2473
+ */
2474
+
2475
+ /**
2476
+ * Multi file upload hooks
2477
+ *
2478
+ * @typedef {object} MultiFileUploadHooks
2479
+ * @property {OnUploadFileEntryHook} [entryHook] - File upload entry hook
2480
+ * @property {OnUploadFileExitHook} [exitHook] - File upload exit hook
2481
+ * @property {OnUploadFileErrorHook} [errorHook] - File upload error hook
2482
+ * @property {OnUploadFileDeleteHook} [deleteHook] - File delete hook
2483
+ */
2484
+
2485
+ /**
2486
+ * Upload hook: File entry
2487
+ *
2488
+ * @callback OnUploadFileEntryHook
2489
+ * @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
2490
+ * @param {File} file - File upload
2491
+ */
2492
+
2493
+ /**
2494
+ * Upload hook: File exit
2495
+ *
2496
+ * @callback OnUploadFileExitHook
2497
+ * @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
2498
+ * @param {File} file - File upload
2499
+ * @param {XMLHttpRequest} xhr - XMLHttpRequest
2500
+ * @param {string} textStatus - Text status
2501
+ */
2502
+
2503
+ /**
2504
+ * Upload hook: File error
2505
+ *
2506
+ * @callback OnUploadFileErrorHook
2507
+ * @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
2508
+ * @param {File} file - File upload
2509
+ * @param {XMLHttpRequest} xhr - XMLHttpRequest
2510
+ * @param {string} textStatus - Text status
2511
+ * @param {Error} errorThrown - Error thrown
2512
+ */
2513
+
2514
+ /**
2515
+ * Upload hook: File delete
2516
+ *
2517
+ * @callback OnUploadFileDeleteHook
2518
+ * @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
2519
+ * @param {File} [file] - File upload
2520
+ * @param {XMLHttpRequest} xhr - XMLHttpRequest
2521
+ * @param {string} textStatus - Text status
2522
+ */
2523
+
2524
+ /**
2525
+ * @typedef {object} UploadResponseSuccess
2526
+ * @property {{ messageText: string, messageHtml: string }} success - Response success
2527
+ * @property {UploadResponseFile} file - Response file
2528
+ */
2529
+
2530
+ /**
2531
+ * @typedef {object} UploadResponseError
2532
+ * @property {{ message: string }} error - Response error
2533
+ * @property {UploadResponseFile} file - Response file
2534
+ */
2535
+
2536
+ /**
2537
+ * @typedef {object} UploadResponseFile
2538
+ * @property {string} filename - File name
2539
+ * @property {string} originalname - Original file name
2540
+ */
2541
+
2542
+ /**
2543
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
2544
+ */
2545
+ MultiFileUpload.moduleName = 'moj-multi-file-upload';
2546
+ /**
2547
+ * Multi file upload default config
2548
+ *
2549
+ * @type {MultiFileUploadConfig}
2550
+ */
2551
+ MultiFileUpload.defaults = Object.freeze({
2552
+ uploadStatusText: 'Uploading files, please wait',
2553
+ dropzoneHintText: 'Drag and drop files here or',
2554
+ dropzoneButtonText: 'Choose files',
2555
+ feedbackContainer: {
2556
+ selector: '.moj-multi-file__uploaded-files'
2557
+ },
2558
+ hooks: {
2559
+ entryHook: () => {},
2560
+ exitHook: () => {},
2561
+ errorHook: () => {},
2562
+ deleteHook: () => {}
2563
+ }
2564
+ });
2565
+ /**
2566
+ * Multi file upload config schema
2567
+ *
2568
+ * @satisfies {Schema<MultiFileUploadConfig>}
2569
+ */
2570
+ MultiFileUpload.schema = Object.freeze(/** @type {const} */{
2571
+ properties: {
2572
+ uploadUrl: {
2573
+ type: 'string'
2574
+ },
2575
+ deleteUrl: {
2576
+ type: 'string'
2577
+ },
2578
+ uploadStatusText: {
2579
+ type: 'string'
2580
+ },
2581
+ dropzoneHintText: {
2582
+ type: 'string'
2583
+ },
2584
+ dropzoneButtonText: {
2585
+ type: 'string'
2586
+ },
2587
+ feedbackContainer: {
2588
+ type: 'object'
2589
+ },
2590
+ hooks: {
2591
+ type: 'object'
2592
+ }
2593
+ }
2594
+ });
2595
+
2596
+ /**
2597
+ * @augments {ConfigurableComponent<MultiSelectConfig>}
2598
+ */
2599
+ class MultiSelect extends ConfigurableComponent {
2600
+ /**
2601
+ * @param {Element | null} $root - HTML element to use for multi select
2602
+ * @param {MultiSelectConfig} [config] - Multi select config
2603
+ */
2604
+ constructor($root, config = {}) {
2605
+ var _this$config$checkbox;
2606
+ super($root, config);
2607
+ const $container = this.$root.querySelector(`#${this.config.idPrefix}select-all`);
2608
+ const $checkboxes = /** @type {NodeListOf<HTMLInputElement>} */(_this$config$checkbox = this.config.checkboxes.items) != null ? _this$config$checkbox : this.$root.querySelectorAll(this.config.checkboxes.selector);
2609
+ if (!$container || !($container instanceof HTMLElement) || !$checkboxes.length) {
1992
2610
  return this;
1993
2611
  }
1994
- this.container.setAttribute('data-moj-multi-select-init', '');
1995
- const idPrefix = options.id_prefix;
1996
- this.setupToggle(idPrefix);
1997
- this.toggleButton = this.toggle.querySelector('input');
1998
- this.toggleButton.addEventListener('click', this.onButtonClick.bind(this));
1999
- this.container.append(this.toggle);
2000
- this.checkboxes = Array.from(options.checkboxes);
2001
- this.checkboxes.forEach(el => el.addEventListener('click', this.onCheckboxClick.bind(this)));
2002
- this.checked = options.checked || false;
2612
+ this.setupToggle(this.config.idPrefix);
2613
+ this.$toggleButton = this.$toggle.querySelector('input');
2614
+ this.$toggleButton.addEventListener('click', this.onButtonClick.bind(this));
2615
+ this.$container = $container;
2616
+ this.$container.append(this.$toggle);
2617
+ this.$checkboxes = Array.from($checkboxes);
2618
+ this.$checkboxes.forEach($input => $input.addEventListener('click', this.onCheckboxClick.bind(this)));
2619
+ this.checked = config.checked || false;
2003
2620
  }
2004
2621
  setupToggle(idPrefix = '') {
2005
2622
  const id = `${idPrefix}checkboxes-all`;
2006
- const toggle = document.createElement('div');
2007
- const label = document.createElement('label');
2008
- const input = document.createElement('input');
2009
- const span = document.createElement('span');
2010
- toggle.classList.add('govuk-checkboxes__item', 'govuk-checkboxes--small', 'moj-multi-select__checkbox');
2011
- input.id = id;
2012
- input.type = 'checkbox';
2013
- input.classList.add('govuk-checkboxes__input');
2014
- label.setAttribute('for', id);
2015
- label.classList.add('govuk-label', 'govuk-checkboxes__label', 'moj-multi-select__toggle-label');
2016
- span.classList.add('govuk-visually-hidden');
2017
- span.textContent = 'Select all';
2018
- label.append(span);
2019
- toggle.append(input, label);
2020
- this.toggle = toggle;
2623
+ const $toggle = document.createElement('div');
2624
+ const $label = document.createElement('label');
2625
+ const $input = document.createElement('input');
2626
+ const $span = document.createElement('span');
2627
+ $toggle.classList.add('govuk-checkboxes__item', 'govuk-checkboxes--small', 'moj-multi-select__checkbox');
2628
+ $input.id = id;
2629
+ $input.type = 'checkbox';
2630
+ $input.classList.add('govuk-checkboxes__input');
2631
+ $label.setAttribute('for', id);
2632
+ $label.classList.add('govuk-label', 'govuk-checkboxes__label', 'moj-multi-select__toggle-label');
2633
+ $span.classList.add('govuk-visually-hidden');
2634
+ $span.textContent = 'Select all';
2635
+ $label.append($span);
2636
+ $toggle.append($input, $label);
2637
+ this.$toggle = $toggle;
2021
2638
  }
2022
2639
  onButtonClick() {
2023
2640
  if (this.checked) {
2024
2641
  this.uncheckAll();
2025
- this.toggleButton.checked = false;
2642
+ this.$toggleButton.checked = false;
2026
2643
  } else {
2027
2644
  this.checkAll();
2028
- this.toggleButton.checked = true;
2645
+ this.$toggleButton.checked = true;
2029
2646
  }
2030
2647
  }
2031
2648
  checkAll() {
2032
- this.checkboxes.forEach(el => {
2033
- el.checked = true;
2649
+ this.$checkboxes.forEach($input => {
2650
+ $input.checked = true;
2034
2651
  });
2035
2652
  this.checked = true;
2036
2653
  }
2037
2654
  uncheckAll() {
2038
- this.checkboxes.forEach(el => {
2039
- el.checked = false;
2655
+ this.$checkboxes.forEach($input => {
2656
+ $input.checked = false;
2040
2657
  });
2041
2658
  this.checked = false;
2042
2659
  }
2660
+
2661
+ /**
2662
+ * @param {MouseEvent} event - Click event
2663
+ */
2043
2664
  onCheckboxClick(event) {
2665
+ if (!(event.target instanceof HTMLInputElement)) {
2666
+ return;
2667
+ }
2044
2668
  if (!event.target.checked) {
2045
- this.toggleButton.checked = false;
2669
+ this.$toggleButton.checked = false;
2046
2670
  this.checked = false;
2047
2671
  } else {
2048
- if (this.checkboxes.filter(el => el.checked).length === this.checkboxes.length) {
2049
- this.toggleButton.checked = true;
2672
+ if (this.$checkboxes.filter($input => $input.checked).length === this.$checkboxes.length) {
2673
+ this.$toggleButton.checked = true;
2050
2674
  this.checked = true;
2051
2675
  }
2052
2676
  }
2053
2677
  }
2054
- }
2055
2678
 
2056
- class PasswordReveal {
2057
2679
  /**
2058
- * @param {Element | null} element - HTML element to use for password reveal
2680
+ * Name for the component used when initialising using data-module attributes.
2059
2681
  */
2060
- constructor(element) {
2061
- if (!element || !(element instanceof HTMLInputElement)) {
2062
- return this;
2682
+ }
2683
+
2684
+ /**
2685
+ * Multi select config
2686
+ *
2687
+ * @typedef {object} MultiSelectConfig
2688
+ * @property {string} [idPrefix] - Prefix for the Select all" checkbox `id` attribute
2689
+ * @property {boolean} [checked] - Whether the "Select all" checkbox is checked
2690
+ * @property {object} [checkboxes] - Checkboxes config
2691
+ * @property {string} [checkboxes.selector] - Checkboxes query selector
2692
+ * @property {NodeListOf<HTMLInputElement>} [checkboxes.items] - Checkboxes query selector results
2693
+ */
2694
+
2695
+ /**
2696
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
2697
+ */
2698
+ MultiSelect.moduleName = 'moj-multi-select';
2699
+ /**
2700
+ * Multi select config
2701
+ *
2702
+ * @type {MultiSelectConfig}
2703
+ */
2704
+ MultiSelect.defaults = Object.freeze({
2705
+ idPrefix: '',
2706
+ checkboxes: {
2707
+ selector: 'tbody input.govuk-checkboxes__input'
2708
+ }
2709
+ });
2710
+ /**
2711
+ * Multi select config schema
2712
+ *
2713
+ * @satisfies {Schema<MultiSelectConfig>}
2714
+ */
2715
+ MultiSelect.schema = Object.freeze(/** @type {const} */{
2716
+ properties: {
2717
+ idPrefix: {
2718
+ type: 'string'
2719
+ },
2720
+ checked: {
2721
+ type: 'boolean'
2722
+ },
2723
+ checkboxes: {
2724
+ type: 'object'
2063
2725
  }
2064
- this.el = element;
2065
- this.container = element.parentElement;
2066
- if (this.container.hasAttribute('data-moj-password-reveal-init')) {
2726
+ }
2727
+ });
2728
+
2729
+ class PasswordReveal extends Component {
2730
+ /**
2731
+ * @param {Element | null} $root - HTML element to use for password reveal
2732
+ */
2733
+ constructor($root) {
2734
+ super($root);
2735
+ const $input = this.$root.querySelector('.govuk-input');
2736
+ if (!$input || !($input instanceof HTMLInputElement)) {
2067
2737
  return this;
2068
2738
  }
2069
- this.container.setAttribute('data-moj-password-reveal-init', '');
2070
- this.el.setAttribute('spellcheck', 'false');
2739
+ this.$input = $input;
2740
+ this.$input.setAttribute('spellcheck', 'false');
2071
2741
  this.createButton();
2072
2742
  }
2073
2743
  createButton() {
2074
- this.group = document.createElement('div');
2075
- this.button = document.createElement('button');
2076
- this.button.setAttribute('type', 'button');
2077
- this.group.className = 'moj-password-reveal';
2078
- this.button.className = 'govuk-button govuk-button--secondary moj-password-reveal__button';
2079
- this.button.innerHTML = 'Show <span class="govuk-visually-hidden">password</span>';
2080
- this.button.addEventListener('click', this.onButtonClick.bind(this));
2081
- this.group.append(this.el, this.button);
2082
- this.container.append(this.group);
2744
+ this.$group = document.createElement('div');
2745
+ this.$button = document.createElement('button');
2746
+ this.$button.setAttribute('type', 'button');
2747
+ this.$root.classList.add('moj-password-reveal');
2748
+ this.$group.classList.add('moj-password-reveal__wrapper');
2749
+ this.$button.classList.add('govuk-button', 'govuk-button--secondary', 'moj-password-reveal__button');
2750
+ this.$button.innerHTML = 'Show <span class="govuk-visually-hidden">password</span>';
2751
+ this.$button.addEventListener('click', this.onButtonClick.bind(this));
2752
+ this.$group.append(this.$input, this.$button);
2753
+ this.$root.append(this.$group);
2083
2754
  }
2084
2755
  onButtonClick() {
2085
- if (this.el.type === 'password') {
2086
- this.el.type = 'text';
2087
- this.button.innerHTML = 'Hide <span class="govuk-visually-hidden">password</span>';
2756
+ if (this.$input.type === 'password') {
2757
+ this.$input.type = 'text';
2758
+ this.$button.innerHTML = 'Hide <span class="govuk-visually-hidden">password</span>';
2088
2759
  } else {
2089
- this.el.type = 'password';
2090
- this.button.innerHTML = 'Show <span class="govuk-visually-hidden">password</span>';
2760
+ this.$input.type = 'password';
2761
+ this.$button.innerHTML = 'Show <span class="govuk-visually-hidden">password</span>';
2091
2762
  }
2092
2763
  }
2764
+
2765
+ /**
2766
+ * Name for the component used when initialising using data-module attributes.
2767
+ */
2093
2768
  }
2769
+ PasswordReveal.moduleName = 'moj-password-reveal';
2094
2770
 
2095
- class RichTextEditor {
2771
+ /**
2772
+ * @augments {ConfigurableComponent<RichTextEditorConfig>}
2773
+ */
2774
+ class RichTextEditor extends ConfigurableComponent {
2096
2775
  /**
2097
- * @param {RichTextEditorConfig} options
2776
+ * @param {Element | null} $root - HTML element to use for rich text editor
2777
+ * @param {RichTextEditorConfig} config
2098
2778
  */
2099
- constructor(options = {}) {
2100
- const {
2101
- textarea
2102
- } = options;
2103
- if (!textarea || !textarea.parentElement || !(textarea instanceof HTMLTextAreaElement) || !('contentEditable' in document.documentElement)) {
2779
+ constructor($root, config = {}) {
2780
+ super($root, config);
2781
+ if (!RichTextEditor.isSupported()) {
2104
2782
  return this;
2105
2783
  }
2106
- options.toolbar = options.toolbar || {
2107
- bold: false,
2108
- italic: false,
2109
- underline: false,
2110
- bullets: true,
2111
- numbers: true
2112
- };
2113
- this.textarea = textarea;
2114
- this.container = this.textarea.parentElement;
2115
- this.options = options;
2116
- if (this.container.hasAttribute('data-rich-text-editor-init')) {
2784
+ const $textarea = this.$root.querySelector('.govuk-textarea');
2785
+ if (!$textarea || !($textarea instanceof HTMLTextAreaElement)) {
2117
2786
  return this;
2118
2787
  }
2119
- this.container.setAttribute('data-rich-text-editor-init', '');
2788
+ this.$textarea = $textarea;
2120
2789
  this.createToolbar();
2121
2790
  this.hideDefault();
2122
2791
  this.configureToolbar();
@@ -2126,34 +2795,42 @@ class RichTextEditor {
2126
2795
  up: 38,
2127
2796
  down: 40
2128
2797
  };
2129
- this.content.addEventListener('input', this.onEditorInput.bind(this));
2130
- this.container.querySelector('label').addEventListener('click', this.onLabelClick.bind(this));
2131
- this.toolbar.addEventListener('keydown', this.onToolbarKeydown.bind(this));
2798
+ this.$content.addEventListener('input', this.onEditorInput.bind(this));
2799
+ this.$root.querySelector('label').addEventListener('click', this.onLabelClick.bind(this));
2800
+ this.$toolbar.addEventListener('keydown', this.onToolbarKeydown.bind(this));
2132
2801
  }
2802
+
2803
+ /**
2804
+ * @param {KeyboardEvent} event - Click event
2805
+ */
2133
2806
  onToolbarKeydown(event) {
2134
- let focusableButton;
2807
+ let $focusableButton;
2135
2808
  switch (event.keyCode) {
2136
2809
  case this.keys.right:
2137
2810
  case this.keys.down:
2138
2811
  {
2139
- focusableButton = this.buttons.find(button => button.getAttribute('tabindex') === '0');
2140
- const nextButton = focusableButton.nextElementSibling;
2141
- if (nextButton instanceof HTMLButtonElement) {
2142
- nextButton.focus();
2143
- focusableButton.setAttribute('tabindex', '-1');
2144
- nextButton.setAttribute('tabindex', '0');
2812
+ $focusableButton = this.$buttons.find(button => button.getAttribute('tabindex') === '0');
2813
+ if ($focusableButton) {
2814
+ const $nextButton = $focusableButton.nextElementSibling;
2815
+ if ($nextButton && $nextButton instanceof HTMLButtonElement) {
2816
+ $nextButton.focus();
2817
+ $focusableButton.setAttribute('tabindex', '-1');
2818
+ $nextButton.setAttribute('tabindex', '0');
2819
+ }
2145
2820
  }
2146
2821
  break;
2147
2822
  }
2148
2823
  case this.keys.left:
2149
2824
  case this.keys.up:
2150
2825
  {
2151
- focusableButton = this.buttons.find(button => button.getAttribute('tabindex') === '0');
2152
- const previousButton = focusableButton.previousElementSibling;
2153
- if (previousButton instanceof HTMLButtonElement) {
2154
- previousButton.focus();
2155
- focusableButton.setAttribute('tabindex', '-1');
2156
- previousButton.setAttribute('tabindex', '0');
2826
+ $focusableButton = this.$buttons.find(button => button.getAttribute('tabindex') === '0');
2827
+ if ($focusableButton) {
2828
+ const $previousButton = $focusableButton.previousElementSibling;
2829
+ if ($previousButton && $previousButton instanceof HTMLButtonElement) {
2830
+ $previousButton.focus();
2831
+ $focusableButton.setAttribute('tabindex', '-1');
2832
+ $previousButton.setAttribute('tabindex', '0');
2833
+ }
2157
2834
  }
2158
2835
  break;
2159
2836
  }
@@ -2162,19 +2839,19 @@ class RichTextEditor {
2162
2839
  getToolbarHtml() {
2163
2840
  let html = '';
2164
2841
  html += '<div class="moj-rich-text-editor__toolbar" role="toolbar">';
2165
- if (this.options.toolbar.bold) {
2842
+ if (this.config.toolbar.bold) {
2166
2843
  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>';
2167
2844
  }
2168
- if (this.options.toolbar.italic) {
2845
+ if (this.config.toolbar.italic) {
2169
2846
  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>';
2170
2847
  }
2171
- if (this.options.toolbar.underline) {
2848
+ if (this.config.toolbar.underline) {
2172
2849
  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>';
2173
2850
  }
2174
- if (this.options.toolbar.bullets) {
2851
+ if (this.config.toolbar.bullets) {
2175
2852
  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>';
2176
2853
  }
2177
- if (this.options.toolbar.numbers) {
2854
+ if (this.config.toolbar.numbers) {
2178
2855
  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>';
2179
2856
  }
2180
2857
  html += '</div>';
@@ -2184,27 +2861,31 @@ class RichTextEditor {
2184
2861
  return `${this.getToolbarHtml()}<div class="govuk-textarea moj-rich-text-editor__content" contenteditable="true" spellcheck="false"></div>`;
2185
2862
  }
2186
2863
  hideDefault() {
2187
- this.textarea.classList.add('govuk-visually-hidden');
2188
- this.textarea.setAttribute('aria-hidden', 'true');
2189
- this.textarea.setAttribute('tabindex', '-1');
2864
+ this.$textarea.classList.add('govuk-visually-hidden');
2865
+ this.$textarea.setAttribute('aria-hidden', 'true');
2866
+ this.$textarea.setAttribute('tabindex', '-1');
2190
2867
  }
2191
2868
  createToolbar() {
2192
- this.toolbar = document.createElement('div');
2193
- this.toolbar.className = 'moj-rich-text-editor';
2194
- this.toolbar.innerHTML = this.getEnhancedHtml();
2195
- this.container.append(this.toolbar);
2196
- this.content = /** @type {HTMLDivElement} */
2197
- this.container.querySelector('.moj-rich-text-editor__content');
2198
- this.content.innerHTML = this.$textarea.value;
2869
+ this.$toolbar = document.createElement('div');
2870
+ this.$toolbar.className = 'moj-rich-text-editor';
2871
+ this.$toolbar.innerHTML = this.getEnhancedHtml();
2872
+ this.$root.append(this.$toolbar);
2873
+ this.$content = /** @type {HTMLElement} */
2874
+ this.$root.querySelector('.moj-rich-text-editor__content');
2875
+ this.$content.innerHTML = this.$textarea.value;
2199
2876
  }
2200
2877
  configureToolbar() {
2201
- this.buttons = Array.from(/** @type {NodeListOf<HTMLButtonElement>} */
2202
- this.container.querySelectorAll('.moj-rich-text-editor__toolbar-button'));
2203
- this.buttons.forEach((button, index) => {
2204
- button.setAttribute('tabindex', !index ? '0' : '-1');
2205
- button.addEventListener('click', this.onButtonClick.bind(this));
2878
+ this.$buttons = Array.from(/** @type {NodeListOf<HTMLButtonElement>} */
2879
+ this.$root.querySelectorAll('.moj-rich-text-editor__toolbar-button'));
2880
+ this.$buttons.forEach(($button, index) => {
2881
+ $button.setAttribute('tabindex', !index ? '0' : '-1');
2882
+ $button.addEventListener('click', this.onButtonClick.bind(this));
2206
2883
  });
2207
2884
  }
2885
+
2886
+ /**
2887
+ * @param {MouseEvent} event - Click event
2888
+ */
2208
2889
  onButtonClick(event) {
2209
2890
  if (!(event.currentTarget instanceof HTMLElement)) {
2210
2891
  return;
@@ -2212,290 +2893,400 @@ class RichTextEditor {
2212
2893
  document.execCommand(event.currentTarget.getAttribute('data-command'), false, undefined);
2213
2894
  }
2214
2895
  getContent() {
2215
- return this.content.innerHTML;
2896
+ return this.$content.innerHTML;
2216
2897
  }
2217
2898
  onEditorInput() {
2218
2899
  this.updateTextarea();
2219
2900
  }
2220
2901
  updateTextarea() {
2221
2902
  document.execCommand('defaultParagraphSeparator', false, 'p');
2222
- this.textarea.value = this.getContent();
2903
+ this.$textarea.value = this.getContent();
2223
2904
  }
2905
+
2906
+ /**
2907
+ * @param {MouseEvent} event - Click event
2908
+ */
2224
2909
  onLabelClick(event) {
2225
2910
  event.preventDefault();
2226
- this.content.focus();
2911
+ this.$content.focus();
2227
2912
  }
2913
+ static isSupported() {
2914
+ return 'contentEditable' in document.documentElement;
2915
+ }
2916
+
2917
+ /**
2918
+ * Name for the component used when initialising using data-module attributes.
2919
+ */
2228
2920
  }
2229
2921
 
2230
- class SearchToggle {
2231
- constructor(options) {
2232
- this.options = options;
2233
- this.container = this.options.search.container;
2234
- this.toggleButtonContainer = this.options.toggleButton.container;
2235
- if (this.container.hasAttribute('data-moj-search-toggle-init')) {
2922
+ /**
2923
+ * Rich text editor config
2924
+ *
2925
+ * @typedef {object} RichTextEditorConfig
2926
+ * @property {RichTextEditorToolbar} [toolbar] - Toolbar options
2927
+ */
2928
+
2929
+ /**
2930
+ * Rich text editor toolbar options
2931
+ *
2932
+ * @typedef {object} RichTextEditorToolbar
2933
+ * @property {boolean} [bold] - Show the bold button
2934
+ * @property {boolean} [italic] - Show the italic button
2935
+ * @property {boolean} [underline] - Show the underline button
2936
+ * @property {boolean} [bullets] - Show the bullets button
2937
+ * @property {boolean} [numbers] - Show the numbers button
2938
+ */
2939
+
2940
+ /**
2941
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
2942
+ */
2943
+ RichTextEditor.moduleName = 'moj-rich-text-editor';
2944
+ /**
2945
+ * Rich text editor config
2946
+ *
2947
+ * @type {RichTextEditorConfig}
2948
+ */
2949
+ RichTextEditor.defaults = Object.freeze({
2950
+ toolbar: {
2951
+ bold: false,
2952
+ italic: false,
2953
+ underline: false,
2954
+ bullets: true,
2955
+ numbers: true
2956
+ }
2957
+ });
2958
+ /**
2959
+ * Rich text editor config schema
2960
+ *
2961
+ * @satisfies {Schema<RichTextEditorConfig>}
2962
+ */
2963
+ RichTextEditor.schema = Object.freeze(/** @type {const} */{
2964
+ properties: {
2965
+ toolbar: {
2966
+ type: 'object'
2967
+ }
2968
+ }
2969
+ });
2970
+
2971
+ /**
2972
+ * @augments {ConfigurableComponent<SearchToggleConfig>}
2973
+ */
2974
+ class SearchToggle extends ConfigurableComponent {
2975
+ /**
2976
+ * @param {Element | null} $root - HTML element to use for search toggle
2977
+ * @param {SearchToggleConfig} [config] - Search toggle config
2978
+ */
2979
+ constructor($root, config = {}) {
2980
+ var _this$config$searchCo, _this$config$toggleBu;
2981
+ super($root, config);
2982
+ const $searchContainer = (_this$config$searchCo = this.config.searchContainer.element) != null ? _this$config$searchCo : this.$root.querySelector(this.config.searchContainer.selector);
2983
+ const $toggleButtonContainer = (_this$config$toggleBu = this.config.toggleButtonContainer.element) != null ? _this$config$toggleBu : this.$root.querySelector(this.config.toggleButtonContainer.selector);
2984
+ if (!$searchContainer || !$toggleButtonContainer || !($searchContainer instanceof HTMLElement) || !($toggleButtonContainer instanceof HTMLElement)) {
2236
2985
  return this;
2237
2986
  }
2238
- this.container.setAttribute('data-moj-search-toggle-init', '');
2987
+ this.$searchContainer = $searchContainer;
2988
+ this.$toggleButtonContainer = $toggleButtonContainer;
2239
2989
  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>';
2240
- this.toggleButton = document.createElement('button');
2241
- this.toggleButton.setAttribute('class', 'moj-search-toggle__button');
2242
- this.toggleButton.setAttribute('type', 'button');
2243
- this.toggleButton.setAttribute('aria-haspopup', 'true');
2244
- this.toggleButton.setAttribute('aria-expanded', 'false');
2245
- this.toggleButton.innerHTML = `${this.options.toggleButton.text} ${svg}`;
2246
- this.toggleButton.addEventListener('click', this.onToggleButtonClick.bind(this));
2247
- this.toggleButtonContainer.append(this.toggleButton);
2990
+ this.$toggleButton = document.createElement('button');
2991
+ this.$toggleButton.setAttribute('class', 'moj-search-toggle__button');
2992
+ this.$toggleButton.setAttribute('type', 'button');
2993
+ this.$toggleButton.setAttribute('aria-haspopup', 'true');
2994
+ this.$toggleButton.setAttribute('aria-expanded', 'false');
2995
+ this.$toggleButton.innerHTML = `${this.config.toggleButton.text} ${svg}`;
2996
+ this.$toggleButton.addEventListener('click', this.onToggleButtonClick.bind(this));
2997
+ this.$toggleButtonContainer.append(this.$toggleButton);
2248
2998
  document.addEventListener('click', this.onDocumentClick.bind(this));
2249
2999
  document.addEventListener('focusin', this.onDocumentClick.bind(this));
2250
3000
  }
2251
3001
  showMenu() {
2252
- this.toggleButton.setAttribute('aria-expanded', 'true');
2253
- this.container.classList.remove('moj-js-hidden');
2254
- this.container.querySelector('input').focus();
3002
+ this.$toggleButton.setAttribute('aria-expanded', 'true');
3003
+ this.$searchContainer.classList.remove('moj-js-hidden');
3004
+ this.$searchContainer.querySelector('input').focus();
2255
3005
  }
2256
3006
  hideMenu() {
2257
- this.container.classList.add('moj-js-hidden');
2258
- this.toggleButton.setAttribute('aria-expanded', 'false');
3007
+ this.$searchContainer.classList.add('moj-js-hidden');
3008
+ this.$toggleButton.setAttribute('aria-expanded', 'false');
2259
3009
  }
2260
3010
  onToggleButtonClick() {
2261
- if (this.toggleButton.getAttribute('aria-expanded') === 'false') {
3011
+ if (this.$toggleButton.getAttribute('aria-expanded') === 'false') {
2262
3012
  this.showMenu();
2263
3013
  } else {
2264
3014
  this.hideMenu();
2265
3015
  }
2266
3016
  }
3017
+
3018
+ /**
3019
+ * @param {MouseEvent | FocusEvent} event
3020
+ */
2267
3021
  onDocumentClick(event) {
2268
- if (!this.toggleButtonContainer.contains(event.target) && !this.container.contains(event.target)) {
3022
+ if (event.target instanceof Node && !this.$toggleButtonContainer.contains(event.target) && !this.$searchContainer.contains(event.target)) {
2269
3023
  this.hideMenu();
2270
3024
  }
2271
3025
  }
3026
+
3027
+ /**
3028
+ * Name for the component used when initialising using data-module attributes.
3029
+ */
2272
3030
  }
2273
3031
 
2274
- class SortableTable {
2275
- constructor(params) {
2276
- const table = params.table;
2277
- const head = table == null ? void 0 : table.querySelector('thead');
2278
- const body = table == null ? void 0 : table.querySelector('tbody');
2279
- if (!table || !(table instanceof HTMLElement) || !head || !body) {
2280
- return this;
3032
+ /**
3033
+ * @typedef {object} SearchToggleConfig
3034
+ * @property {object} [searchContainer] - Search config
3035
+ * @property {string} [searchContainer.selector] - Selector for search container
3036
+ * @property {Element | null} [searchContainer.element] - HTML element for search container
3037
+ * @property {object} [toggleButton] - Toggle button config
3038
+ * @property {string} [toggleButton.text] - Text for toggle button
3039
+ * @property {object} [toggleButtonContainer] - Toggle button container config
3040
+ * @property {string} [toggleButtonContainer.selector] - Selector for toggle button container
3041
+ * @property {Element | null} [toggleButtonContainer.element] - HTML element for toggle button container
3042
+ */
3043
+
3044
+ /**
3045
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
3046
+ */
3047
+ SearchToggle.moduleName = 'moj-search-toggle';
3048
+ /**
3049
+ * Search toggle config
3050
+ *
3051
+ * @type {SearchToggleConfig}
3052
+ */
3053
+ SearchToggle.defaults = Object.freeze({
3054
+ searchContainer: {
3055
+ selector: '.moj-search'
3056
+ },
3057
+ toggleButton: {
3058
+ text: 'Search'
3059
+ },
3060
+ toggleButtonContainer: {
3061
+ selector: '.moj-search-toggle__toggle'
3062
+ }
3063
+ });
3064
+ /**
3065
+ * Search toggle config schema
3066
+ *
3067
+ * @satisfies {Schema<SearchToggleConfig>}
3068
+ */
3069
+ SearchToggle.schema = Object.freeze(/** @type {const} */{
3070
+ properties: {
3071
+ searchContainer: {
3072
+ type: 'object'
3073
+ },
3074
+ toggleButton: {
3075
+ type: 'object'
3076
+ },
3077
+ toggleButtonContainer: {
3078
+ type: 'object'
2281
3079
  }
2282
- this.table = table;
2283
- this.head = head;
2284
- this.body = body;
2285
- if (this.table.hasAttribute('data-moj-sortable-table-init')) {
3080
+ }
3081
+ });
3082
+
3083
+ /**
3084
+ * @augments {ConfigurableComponent<SortableTableConfig>}
3085
+ */
3086
+ class SortableTable extends ConfigurableComponent {
3087
+ /**
3088
+ * @param {Element | null} $root - HTML element to use for sortable table
3089
+ * @param {SortableTableConfig} [config] - Sortable table config
3090
+ */
3091
+ constructor($root, config = {}) {
3092
+ super($root, config);
3093
+ const $head = $root == null ? void 0 : $root.querySelector('thead');
3094
+ const $body = $root == null ? void 0 : $root.querySelector('tbody');
3095
+ if (!$head || !$body) {
2286
3096
  return this;
2287
3097
  }
2288
- this.table.setAttribute('data-moj-sortable-table-init', '');
2289
- this.headings = this.head ? Array.from(this.head.querySelectorAll('th')) : [];
2290
- this.setupOptions(params);
3098
+ this.$head = $head;
3099
+ this.$body = $body;
3100
+ this.$headings = this.$head ? Array.from(this.$head.querySelectorAll('th')) : [];
2291
3101
  this.createHeadingButtons();
2292
3102
  this.createStatusBox();
2293
3103
  this.initialiseSortedColumn();
2294
- this.head.addEventListener('click', this.onSortButtonClick.bind(this));
2295
- }
2296
- setupOptions(params) {
2297
- params = params || {};
2298
- this.statusMessage = params.statusMessage || 'Sort by %heading% (%direction%)';
2299
- this.ascendingText = params.ascendingText || 'ascending';
2300
- this.descendingText = params.descendingText || 'descending';
3104
+ this.$head.addEventListener('click', this.onSortButtonClick.bind(this));
2301
3105
  }
2302
3106
  createHeadingButtons() {
2303
- for (const heading of this.headings) {
2304
- if (heading.hasAttribute('aria-sort')) {
2305
- this.createHeadingButton(heading);
3107
+ for (const $heading of this.$headings) {
3108
+ if ($heading.hasAttribute('aria-sort')) {
3109
+ this.createHeadingButton($heading);
2306
3110
  }
2307
3111
  }
2308
3112
  }
2309
- createHeadingButton(heading) {
2310
- const index = this.headings.indexOf(heading);
2311
- const button = document.createElement('button');
2312
- button.setAttribute('type', 'button');
2313
- button.setAttribute('data-index', `${index}`);
2314
- button.textContent = heading.textContent;
2315
- heading.textContent = '';
2316
- heading.appendChild(button);
3113
+
3114
+ /**
3115
+ * @param {HTMLTableCellElement} $heading
3116
+ */
3117
+ createHeadingButton($heading) {
3118
+ const index = this.$headings.indexOf($heading);
3119
+ const $button = document.createElement('button');
3120
+ $button.setAttribute('type', 'button');
3121
+ $button.setAttribute('data-index', `${index}`);
3122
+ $button.textContent = $heading.textContent;
3123
+ $heading.textContent = '';
3124
+ $heading.appendChild($button);
2317
3125
  }
2318
3126
  createStatusBox() {
2319
- this.status = document.createElement('div');
2320
- this.status.setAttribute('aria-atomic', 'true');
2321
- this.status.setAttribute('aria-live', 'polite');
2322
- this.status.setAttribute('class', 'govuk-visually-hidden');
2323
- this.status.setAttribute('role', 'status');
2324
- this.table.insertAdjacentElement('afterend', this.status);
3127
+ this.$status = document.createElement('div');
3128
+ this.$status.setAttribute('aria-atomic', 'true');
3129
+ this.$status.setAttribute('aria-live', 'polite');
3130
+ this.$status.setAttribute('class', 'govuk-visually-hidden');
3131
+ this.$status.setAttribute('role', 'status');
3132
+ this.$root.insertAdjacentElement('afterend', this.$status);
2325
3133
  }
2326
3134
  initialiseSortedColumn() {
2327
- var _sortButton$getAttrib;
2328
- const rows = this.getTableRowsArray();
2329
- const heading = this.table.querySelector('th[aria-sort]');
2330
- const sortButton = heading == null ? void 0 : heading.querySelector('button');
2331
- const sortDirection = heading == null ? void 0 : heading.getAttribute('aria-sort');
2332
- const columnNumber = Number.parseInt((_sortButton$getAttrib = sortButton == null ? void 0 : sortButton.getAttribute('data-index')) != null ? _sortButton$getAttrib : '0', 10);
2333
- if (!heading || !sortButton || !(sortDirection === 'ascending' || sortDirection === 'descending')) {
3135
+ var _$sortButton$getAttri;
3136
+ const $rows = this.getTableRowsArray();
3137
+ const $heading = this.$root.querySelector('th[aria-sort]');
3138
+ const $sortButton = $heading == null ? void 0 : $heading.querySelector('button');
3139
+ const sortDirection = $heading == null ? void 0 : $heading.getAttribute('aria-sort');
3140
+ const columnNumber = Number.parseInt((_$sortButton$getAttri = $sortButton == null ? void 0 : $sortButton.getAttribute('data-index')) != null ? _$sortButton$getAttri : '0', 10);
3141
+ if (!$heading || !$sortButton || !(sortDirection === 'ascending' || sortDirection === 'descending')) {
2334
3142
  return;
2335
3143
  }
2336
- const sortedRows = this.sort(rows, columnNumber, sortDirection);
2337
- this.addRows(sortedRows);
3144
+ const $sortedRows = this.sort($rows, columnNumber, sortDirection);
3145
+ this.addRows($sortedRows);
2338
3146
  }
3147
+
3148
+ /**
3149
+ * @param {MouseEvent} event - Click event
3150
+ */
2339
3151
  onSortButtonClick(event) {
2340
- var _button$getAttribute;
2341
- const button = event.target;
2342
- if (!button || !(button instanceof HTMLButtonElement) || !button.parentElement) {
3152
+ var _$button$getAttribute;
3153
+ const $button = event.target;
3154
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.parentElement) {
2343
3155
  return;
2344
3156
  }
2345
- const heading = button.parentElement;
2346
- const sortDirection = heading.getAttribute('aria-sort');
2347
- const columnNumber = Number.parseInt((_button$getAttribute = button == null ? void 0 : button.getAttribute('data-index')) != null ? _button$getAttribute : '0', 10);
3157
+ const $heading = $button.parentElement;
3158
+ const sortDirection = $heading.getAttribute('aria-sort');
3159
+ const columnNumber = Number.parseInt((_$button$getAttribute = $button == null ? void 0 : $button.getAttribute('data-index')) != null ? _$button$getAttribute : '0', 10);
2348
3160
  const newSortDirection = sortDirection === 'none' || sortDirection === 'descending' ? 'ascending' : 'descending';
2349
- const rows = this.getTableRowsArray();
2350
- const sortedRows = this.sort(rows, columnNumber, newSortDirection);
2351
- this.addRows(sortedRows);
3161
+ const $rows = this.getTableRowsArray();
3162
+ const $sortedRows = this.sort($rows, columnNumber, newSortDirection);
3163
+ this.addRows($sortedRows);
2352
3164
  this.removeButtonStates();
2353
- this.updateButtonState(button, newSortDirection);
3165
+ this.updateButtonState($button, newSortDirection);
2354
3166
  }
2355
- updateButtonState(button, direction) {
3167
+
3168
+ /**
3169
+ * @param {HTMLButtonElement} $button
3170
+ * @param {string} direction
3171
+ */
3172
+ updateButtonState($button, direction) {
2356
3173
  if (!(direction === 'ascending' || direction === 'descending')) {
2357
3174
  return;
2358
3175
  }
2359
- button.parentElement.setAttribute('aria-sort', direction);
2360
- let message = this.statusMessage;
2361
- message = message.replace(/%heading%/, button.textContent);
2362
- message = message.replace(/%direction%/, this[`${direction}Text`]);
2363
- this.status.textContent = message;
3176
+ $button.parentElement.setAttribute('aria-sort', direction);
3177
+ let message = this.config.statusMessage;
3178
+ message = message.replace(/%heading%/, $button.textContent);
3179
+ message = message.replace(/%direction%/, this.config[`${direction}Text`]);
3180
+ this.$status.textContent = message;
2364
3181
  }
2365
3182
  removeButtonStates() {
2366
- for (const heading of this.headings) {
2367
- heading.setAttribute('aria-sort', 'none');
3183
+ for (const $heading of this.$headings) {
3184
+ $heading.setAttribute('aria-sort', 'none');
2368
3185
  }
2369
3186
  }
2370
- addRows(rows) {
2371
- for (const row of rows) {
2372
- this.body.append(row);
3187
+
3188
+ /**
3189
+ * @param {HTMLTableRowElement[]} $rows
3190
+ */
3191
+ addRows($rows) {
3192
+ for (const $row of $rows) {
3193
+ this.$body.append($row);
2373
3194
  }
2374
3195
  }
2375
3196
  getTableRowsArray() {
2376
- return Array.from(this.body.querySelectorAll('tr'));
3197
+ return Array.from(this.$body.querySelectorAll('tr'));
2377
3198
  }
2378
- sort(rows, columnNumber, sortDirection) {
2379
- return rows.sort((rowA, rowB) => {
2380
- const tdA = rowA.querySelectorAll('td, th')[columnNumber];
2381
- const tdB = rowB.querySelectorAll('td, th')[columnNumber];
2382
- if (!tdA || !tdB || !(tdA instanceof HTMLElement) || !(tdB instanceof HTMLElement)) {
3199
+
3200
+ /**
3201
+ * @param {HTMLTableRowElement[]} $rows
3202
+ * @param {number} columnNumber
3203
+ * @param {string} sortDirection
3204
+ */
3205
+ sort($rows, columnNumber, sortDirection) {
3206
+ return $rows.sort(($rowA, $rowB) => {
3207
+ const $tdA = $rowA.querySelectorAll('td, th')[columnNumber];
3208
+ const $tdB = $rowB.querySelectorAll('td, th')[columnNumber];
3209
+ if (!$tdA || !$tdB || !($tdA instanceof HTMLElement) || !($tdB instanceof HTMLElement)) {
2383
3210
  return 0;
2384
3211
  }
2385
- const valueA = sortDirection === 'ascending' ? this.getCellValue(tdA) : this.getCellValue(tdB);
2386
- const valueB = sortDirection === 'ascending' ? this.getCellValue(tdB) : this.getCellValue(tdA);
3212
+ const valueA = sortDirection === 'ascending' ? this.getCellValue($tdA) : this.getCellValue($tdB);
3213
+ const valueB = sortDirection === 'ascending' ? this.getCellValue($tdB) : this.getCellValue($tdA);
2387
3214
  return !(typeof valueA === 'number' && typeof valueB === 'number') ? valueA.toString().localeCompare(valueB.toString()) : valueA - valueB;
2388
3215
  });
2389
3216
  }
2390
- getCellValue(cell) {
2391
- const val = cell.getAttribute('data-sort-value') || cell.innerHTML;
3217
+
3218
+ /**
3219
+ * @param {HTMLElement} $cell
3220
+ */
3221
+ getCellValue($cell) {
3222
+ const val = $cell.getAttribute('data-sort-value') || $cell.innerHTML;
2392
3223
  const valAsNumber = Number(val);
2393
3224
  return Number.isFinite(valAsNumber) ? valAsNumber // Exclude invalid numbers, infinity etc
2394
3225
  : val;
2395
3226
  }
2396
- }
2397
-
2398
- const version = '0.0.0-development';
2399
3227
 
2400
- /* eslint-disable no-new */
3228
+ /**
3229
+ * Name for the component used when initialising using data-module attributes.
3230
+ */
3231
+ }
2401
3232
 
3233
+ /**
3234
+ * Sortable table config
3235
+ *
3236
+ * @typedef {object} SortableTableConfig
3237
+ * @property {string} [statusMessage] - Status message
3238
+ * @property {string} [ascendingText] - Ascending text
3239
+ * @property {string} [descendingText] - Descending text
3240
+ */
2402
3241
 
2403
3242
  /**
2404
- * @param {Config} [config]
3243
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
2405
3244
  */
2406
- function initAll(config) {
2407
- // Set the config to an empty object by default if no config is passed.
2408
- config = typeof config !== 'undefined' ? config : {};
2409
-
2410
- // Allow the user to initialise MOJ Frontend in only certain sections of the page
2411
- // Defaults to the entire document if nothing is set.
2412
- const scope = typeof config.scope !== 'undefined' ? config.scope : document;
2413
- const $addAnothers = scope.querySelectorAll('[data-module="moj-add-another"]');
2414
- $addAnothers.forEach($addAnother => {
2415
- new AddAnother($addAnother);
2416
- });
2417
- const $multiSelects = scope.querySelectorAll('[data-module="moj-multi-select"]');
2418
- $multiSelects.forEach($multiSelect => {
2419
- const containerSelector = $multiSelect.getAttribute('data-multi-select-checkbox');
2420
- if (!($multiSelect instanceof HTMLElement) || !containerSelector) {
2421
- return;
2422
- }
2423
- new MultiSelect({
2424
- container: $multiSelect.querySelector(containerSelector),
2425
- checkboxes: $multiSelect.querySelectorAll('tbody .govuk-checkboxes__input'),
2426
- id_prefix: $multiSelect.getAttribute('data-multi-select-idprefix')
2427
- });
2428
- });
2429
- const $passwordReveals = scope.querySelectorAll('[data-module="moj-password-reveal"]');
2430
- $passwordReveals.forEach($passwordReveal => {
2431
- new PasswordReveal($passwordReveal);
2432
- });
2433
- const $richTextEditors = scope.querySelectorAll('[data-module="moj-rich-text-editor"]');
2434
- $richTextEditors.forEach($richTextEditor => {
2435
- const options = {
2436
- textarea: $richTextEditor
2437
- };
2438
- const toolbarAttr = $richTextEditor.getAttribute('data-moj-rich-text-editor-toolbar');
2439
- if (toolbarAttr) {
2440
- const toolbar = toolbarAttr.split(',');
2441
- options.toolbar = {};
2442
- for (const option of toolbar) {
2443
- if (option === 'bold' || option === 'italic' || option === 'underline' || option === 'bullets' || option === 'numbers') {
2444
- options.toolbar[option] = true;
2445
- }
2446
- }
3245
+ SortableTable.moduleName = 'moj-sortable-table';
3246
+ /**
3247
+ * Sortable table config
3248
+ *
3249
+ * @type {SortableTableConfig}
3250
+ */
3251
+ SortableTable.defaults = Object.freeze({
3252
+ statusMessage: 'Sort by %heading% (%direction%)',
3253
+ ascendingText: 'ascending',
3254
+ descendingText: 'descending'
3255
+ });
3256
+ /**
3257
+ * Sortable table config schema
3258
+ *
3259
+ * @satisfies {Schema<SortableTableConfig>}
3260
+ */
3261
+ SortableTable.schema = Object.freeze(/** @type {const} */{
3262
+ properties: {
3263
+ statusMessage: {
3264
+ type: 'string'
3265
+ },
3266
+ ascendingText: {
3267
+ type: 'string'
3268
+ },
3269
+ descendingText: {
3270
+ type: 'string'
2447
3271
  }
2448
- new RichTextEditor(options);
2449
- });
2450
- const $searchToggles = scope.querySelectorAll('[data-module="moj-search-toggle"]');
2451
- $searchToggles.forEach($searchToggle => {
2452
- new SearchToggle({
2453
- toggleButton: {
2454
- container: $searchToggle.querySelector('.moj-search-toggle__toggle'),
2455
- text: $searchToggle.getAttribute('data-moj-search-toggle-text')
2456
- },
2457
- search: {
2458
- container: $searchToggle.querySelector('.moj-search')
2459
- }
2460
- });
2461
- });
2462
- const $sortableTables = scope.querySelectorAll('[data-module="moj-sortable-table"]');
2463
- $sortableTables.forEach($table => {
2464
- new SortableTable({
2465
- table: $table
2466
- });
2467
- });
2468
- const $datePickers = scope.querySelectorAll('[data-module="moj-date-picker"]');
2469
- $datePickers.forEach($datePicker => {
2470
- new DatePicker($datePicker);
2471
- });
2472
- const $buttonMenus = scope.querySelectorAll('[data-module="moj-button-menu"]');
2473
- $buttonMenus.forEach($buttonmenu => {
2474
- new ButtonMenu($buttonmenu);
2475
- });
2476
- const $alerts = scope.querySelectorAll('[data-module="moj-alert"]');
2477
- $alerts.forEach($alert => {
2478
- new Alert($alert);
2479
- });
2480
- }
3272
+ }
3273
+ });
2481
3274
 
2482
3275
  /**
2483
- * @typedef {object} Config
2484
- * @property {Element} [scope=document] - Scope to query for components
3276
+ * @param {Config} [config]
2485
3277
  */
3278
+ function initAll(config) {
3279
+ for (const Component of [AddAnother, Alert, ButtonMenu, DatePicker, MultiSelect, PasswordReveal, RichTextEditor, SearchToggle, SortableTable]) {
3280
+ createAll(Component, undefined, config);
3281
+ }
3282
+ }
2486
3283
 
2487
3284
  /**
2488
- * Schema for component config
2489
- *
2490
- * @typedef {object} Schema
2491
- * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
3285
+ * @typedef {Parameters<typeof GOVUKFrontend.initAll>[0]} Config
2492
3286
  */
2493
3287
 
2494
3288
  /**
2495
- * Schema property for component config
2496
- *
2497
- * @typedef {object} SchemaProperty
2498
- * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
3289
+ * @import * as GOVUKFrontend from 'govuk-frontend'
2499
3290
  */
2500
3291
 
2501
3292
  export { AddAnother, Alert, ButtonMenu, DatePicker, FilterToggleButton, FormValidator, MultiFileUpload, MultiSelect, PasswordReveal, RichTextEditor, SearchToggle, SortableTable, initAll, version };