@ministryofjustice/frontend 3.4.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/moj/all.jquery.js +13378 -0
  2. package/moj/all.jquery.min.js +1 -144
  3. package/moj/all.js +2266 -2551
  4. package/moj/all.mjs +126 -0
  5. package/moj/components/add-another/add-another.js +110 -100
  6. package/moj/components/add-another/add-another.mjs +106 -0
  7. package/moj/components/alert/alert.js +319 -211
  8. package/moj/components/alert/alert.mjs +251 -0
  9. package/moj/components/alert/alert.spec.helper.js +12 -5
  10. package/moj/components/alert/alert.spec.helper.mjs +66 -0
  11. package/moj/components/button-menu/button-menu.js +302 -292
  12. package/moj/components/button-menu/button-menu.mjs +329 -0
  13. package/moj/components/date-picker/date-picker.js +850 -842
  14. package/moj/components/date-picker/date-picker.mjs +961 -0
  15. package/moj/components/filter-toggle-button/filter-toggle-button.js +98 -88
  16. package/moj/components/filter-toggle-button/filter-toggle-button.mjs +93 -0
  17. package/moj/components/form-validator/form-validator.js +195 -155
  18. package/moj/components/form-validator/form-validator.mjs +168 -0
  19. package/moj/components/multi-file-upload/multi-file-upload.js +158 -137
  20. package/moj/components/multi-file-upload/multi-file-upload.mjs +219 -0
  21. package/moj/components/multi-select/multi-select.js +75 -65
  22. package/moj/components/multi-select/multi-select.mjs +77 -0
  23. package/moj/components/password-reveal/password-reveal.js +40 -30
  24. package/moj/components/password-reveal/password-reveal.mjs +35 -0
  25. package/moj/components/rich-text-editor/rich-text-editor.js +92 -80
  26. package/moj/components/rich-text-editor/rich-text-editor.mjs +157 -0
  27. package/moj/components/search-toggle/search-toggle.js +55 -45
  28. package/moj/components/search-toggle/search-toggle.mjs +54 -0
  29. package/moj/components/sortable-table/sortable-table.js +141 -141
  30. package/moj/components/sortable-table/sortable-table.mjs +138 -0
  31. package/moj/helpers/_links.scss +1 -1
  32. package/moj/helpers.js +171 -152
  33. package/moj/helpers.mjs +123 -0
  34. package/moj/moj-frontend.min.js +1 -144
  35. package/moj/version.js +11 -1
  36. package/moj/version.mjs +3 -0
  37. package/package.json +13 -1
  38. package/moj/all.spec.js +0 -24
  39. package/moj/components/add-another/add-another.spec.js +0 -165
  40. package/moj/components/alert/alert.spec.js +0 -229
  41. package/moj/components/button-menu/button-menu.spec.js +0 -360
  42. package/moj/components/date-picker/date-picker.spec.js +0 -1178
  43. package/moj/components/filter-toggle-button/filter-toggle-button.spec.js +0 -302
  44. package/moj/components/multi-file-upload/multi-file-upload.spec.js +0 -510
  45. package/moj/components/multi-select/multi-select.spec.js +0 -128
  46. package/moj/components/password-reveal/password-reveal.spec.js +0 -57
  47. package/moj/components/search-toggle/search-toggle.spec.js +0 -129
  48. package/moj/components/sortable-table/sortable-table.spec.js +0 -362
  49. package/moj/helpers.spec.js +0 -235
  50. package/moj/namespace.js +0 -2
@@ -1,247 +1,355 @@
1
- /**
2
- * @typedef {object} AlertConfig
3
- * @property {boolean} [dismissible=false] - Can the alert be dismissed by the user
4
- * @property {string} [dismissText=Dismiss] - the label text for the dismiss button
5
- * @property {boolean} [disableAutoFocus=false] - whether the alert will be autofocused
6
- * @property {string} [focusOnDismissSelector] - CSS Selector for element to be focused on dismiss
7
- */
8
-
9
- /**
10
- * @param {HTMLElement} $module - the Alert element
11
- * @param {AlertConfig} config - configuration options
12
- * @class
13
- */
14
- MOJFrontend.Alert = function ($module, config = {}) {
15
- if (!$module) {
16
- return this
17
- }
1
+ (function (global, factory) {
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.MOJFrontend = global.MOJFrontend || {}));
5
+ })(this, (function (exports) { 'use strict';
18
6
 
19
- const schema = Object.freeze({
20
- properties: {
21
- dismissible: { type: 'boolean' },
22
- dismissText: { type: 'string' },
23
- disableAutoFocus: { type: 'boolean' },
24
- focusOnDismissSelector: { type: 'string' }
7
+ /**
8
+ * Find an elements preceding sibling
9
+ *
10
+ * Utility function to find an elements previous sibling matching the provided
11
+ * selector.
12
+ *
13
+ * @param {HTMLElement} $element - Element to find siblings for
14
+ * @param {string} selector - selector for required sibling
15
+ */
16
+ function getPreviousSibling($element, selector) {
17
+ if (!$element) return
18
+ // Get the previous sibling element
19
+ let $sibling = $element.previousElementSibling;
20
+
21
+ // If the sibling matches our selector, use it
22
+ // If not, jump to the next sibling and continue the loop
23
+ while ($sibling) {
24
+ if ($sibling.matches(selector)) return $sibling
25
+ $sibling = $sibling.previousElementSibling;
25
26
  }
26
- })
27
-
28
- const defaults = {
29
- dismissible: false,
30
- dismissText: 'Dismiss',
31
- disableAutoFocus: false
32
27
  }
33
28
 
34
- // data attributes override JS config, which overrides defaults
35
- this.config = this.mergeConfigs(
36
- defaults,
37
- config,
38
- this.parseDataset(schema, $module.dataset)
39
- )
29
+ function findNearestMatchingElement($element, selector) {
30
+ // If no element or selector is provided, return null
31
+ if (!$element) return
32
+
33
+ // Start with the current element
34
+ let $currentElement = $element;
35
+
36
+ while ($currentElement) {
37
+ // First check the current element
38
+ if ($currentElement.matches(selector)) {
39
+ return $currentElement
40
+ }
41
+
42
+ // Check all previous siblings
43
+ let $sibling = $currentElement.previousElementSibling;
44
+ while ($sibling) {
45
+ // Check if the sibling itself is a heading
46
+ if ($sibling.matches(selector)) {
47
+ return $sibling
48
+ }
49
+ $sibling = $sibling.previousElementSibling;
50
+ }
40
51
 
41
- this.$module = $module
42
- }
52
+ // If no match found in siblings, move up to parent
53
+ $currentElement = $currentElement.parentElement;
54
+ }
55
+ }
43
56
 
44
- MOJFrontend.Alert.prototype.init = function () {
45
57
  /**
46
- * Focus the alert
58
+ * Move focus to element
47
59
  *
48
- * If `role="alert"` is set, focus the element to help some assistive
49
- * technologies prioritise announcing it.
60
+ * Sets tabindex to -1 to make the element programmatically focusable,
61
+ * but removes it on blur as the element doesn't need to be focused again.
50
62
  *
51
- * You can turn off the auto-focus functionality by setting
52
- * `data-disable-auto-focus="true"` in the component HTML. You might wish to
53
- * do this based on user research findings, or to avoid a clash with another
54
- * element which should be focused when the page loads.
63
+ * @param {HTMLElement} $element - HTML element
64
+ * @param {object} [options] - Handler options
65
+ * @param {function(this: HTMLElement): void} [options.onBeforeFocus] - Callback before focus
66
+ * @param {function(this: HTMLElement): void} [options.onBlur] - Callback on blur
55
67
  */
56
- if (
57
- this.$module.getAttribute('role') === 'alert' &&
58
- !this.config.disableAutoFocus
59
- ) {
60
- MOJFrontend.setFocus(this.$module)
61
- }
68
+ function setFocus($element, options = {}) {
69
+ const isFocusable = $element.getAttribute('tabindex');
62
70
 
63
- this.$dismissButton = this.$module.querySelector('.moj-alert__dismiss')
71
+ if (!isFocusable) {
72
+ $element.setAttribute('tabindex', '-1');
73
+ }
64
74
 
65
- if (this.config.dismissible && this.$dismissButton) {
66
- this.$dismissButton.innerHTML = this.config.dismissText
67
- this.$dismissButton.removeAttribute('hidden')
75
+ /**
76
+ * Handle element focus
77
+ */
78
+ function onFocus() {
79
+ $element.addEventListener('blur', onBlur, { once: true });
80
+ }
68
81
 
69
- this.$module.addEventListener('click', (event) => {
70
- if (this.$dismissButton.contains(event.target)) {
71
- this.dimiss()
82
+ /**
83
+ * Handle element blur
84
+ */
85
+ function onBlur() {
86
+ if (options.onBlur) {
87
+ options.onBlur.call($element);
72
88
  }
73
- })
74
- }
75
- }
76
-
77
- /**
78
- * Handle dismissing the alert
79
- */
80
- MOJFrontend.Alert.prototype.dimiss = function () {
81
- let $elementToRecieveFocus
82
-
83
- // If a selector has been provided, attempt to find that element
84
- if (this.config.focusOnDismissSelector) {
85
- $elementToRecieveFocus = document.querySelector(
86
- this.config.focusOnDismissSelector
87
- )
88
- }
89
89
 
90
- // Is the next sibling another alert
91
- if (!$elementToRecieveFocus) {
92
- const $nextSibling = this.$module.nextElementSibling
93
- if ($nextSibling && $nextSibling.matches('.moj-alert')) {
94
- $elementToRecieveFocus = $nextSibling
90
+ if (!isFocusable) {
91
+ $element.removeAttribute('tabindex');
92
+ }
95
93
  }
96
- }
97
94
 
98
- // Else try to find any preceding sibling alert or heading
99
- if (!$elementToRecieveFocus) {
100
- $elementToRecieveFocus = MOJFrontend.getPreviousSibling(
101
- this.$module,
102
- '.moj-alert, h1, h2, h3, h4, h5, h6'
103
- )
104
- }
95
+ // Add listener to reset element on blur, after focus
96
+ $element.addEventListener('focus', onFocus, { once: true });
105
97
 
106
- // Else find the closest ancestor heading, or fallback to main, or last resort
107
- // use the body element
108
- if (!$elementToRecieveFocus) {
109
- $elementToRecieveFocus = MOJFrontend.findNearestMatchingElement(
110
- this.$module,
111
- 'h1, h2, h3, h4, h5, h6, main, body'
112
- )
98
+ // Focus element
99
+ if (options.onBeforeFocus) {
100
+ options.onBeforeFocus.call($element);
101
+ }
102
+ $element.focus();
113
103
  }
114
104
 
115
- // If we have an element, place focus on it
116
- if ($elementToRecieveFocus) {
117
- MOJFrontend.setFocus($elementToRecieveFocus)
118
- }
105
+ /**
106
+ * @typedef {object} AlertConfig
107
+ * @property {boolean} [dismissible=false] - Can the alert be dismissed by the user
108
+ * @property {string} [dismissText=Dismiss] - the label text for the dismiss button
109
+ * @property {boolean} [disableAutoFocus=false] - whether the alert will be autofocused
110
+ * @property {string} [focusOnDismissSelector] - CSS Selector for element to be focused on dismiss
111
+ */
119
112
 
120
- // Remove the alert
121
- this.$module.remove()
122
- }
123
-
124
- /**
125
- * Normalise string
126
- *
127
- * 'If it looks like a duck, and it quacks like a duck…' 🦆
128
- *
129
- * If the passed value looks like a boolean or a number, convert it to a boolean
130
- * or number.
131
- *
132
- * Designed to be used to convert config passed via data attributes (which are
133
- * always strings) into something sensible.
134
- *
135
- * @internal
136
- * @param {DOMStringMap[string]} value - The value to normalise
137
- * @param {SchemaProperty} [property] - Component schema property
138
- * @returns {string | boolean | number | undefined} Normalised data
139
- */
140
- MOJFrontend.Alert.prototype.normaliseString = function (value, property) {
141
- const trimmedValue = value ? value.trim() : ''
142
-
143
- let output
144
- let outputType
145
- if (property && property.type) {
146
- outputType = property.type
113
+ /**
114
+ * @param {HTMLElement} $module - the Alert element
115
+ * @param {AlertConfig} config - configuration options
116
+ * @class
117
+ */
118
+ function Alert($module, config = {}) {
119
+ if (!$module) {
120
+ return this
121
+ }
122
+
123
+ const schema = Object.freeze({
124
+ properties: {
125
+ dismissible: { type: 'boolean' },
126
+ dismissText: { type: 'string' },
127
+ disableAutoFocus: { type: 'boolean' },
128
+ focusOnDismissSelector: { type: 'string' }
129
+ }
130
+ });
131
+
132
+ const defaults = {
133
+ dismissible: false,
134
+ dismissText: 'Dismiss',
135
+ disableAutoFocus: false
136
+ };
137
+
138
+ // data attributes override JS config, which overrides defaults
139
+ this.config = this.mergeConfigs(
140
+ defaults,
141
+ config,
142
+ this.parseDataset(schema, $module.dataset)
143
+ );
144
+
145
+ this.$module = $module;
147
146
  }
148
147
 
149
- // No schema type set? Determine automatically
150
- if (!outputType) {
151
- if (['true', 'false'].includes(trimmedValue)) {
152
- outputType = 'boolean'
148
+ Alert.prototype.init = function () {
149
+ /**
150
+ * Focus the alert
151
+ *
152
+ * If `role="alert"` is set, focus the element to help some assistive
153
+ * technologies prioritise announcing it.
154
+ *
155
+ * You can turn off the auto-focus functionality by setting
156
+ * `data-disable-auto-focus="true"` in the component HTML. You might wish to
157
+ * do this based on user research findings, or to avoid a clash with another
158
+ * element which should be focused when the page loads.
159
+ */
160
+ if (
161
+ this.$module.getAttribute('role') === 'alert' &&
162
+ !this.config.disableAutoFocus
163
+ ) {
164
+ setFocus(this.$module);
153
165
  }
154
166
 
155
- // Empty / whitespace-only strings are considered finite so we need to check
156
- // the length of the trimmed string as well
157
- if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
158
- outputType = 'number'
167
+ this.$dismissButton = this.$module.querySelector('.moj-alert__dismiss');
168
+
169
+ if (this.config.dismissible && this.$dismissButton) {
170
+ this.$dismissButton.innerHTML = this.config.dismissText;
171
+ this.$dismissButton.removeAttribute('hidden');
172
+
173
+ this.$module.addEventListener('click', (event) => {
174
+ if (this.$dismissButton.contains(event.target)) {
175
+ this.dimiss();
176
+ }
177
+ });
159
178
  }
160
- }
179
+ };
161
180
 
162
- switch (outputType) {
163
- case 'boolean':
164
- output = trimmedValue === 'true'
165
- break
181
+ /**
182
+ * Handle dismissing the alert
183
+ */
184
+ Alert.prototype.dimiss = function () {
185
+ let $elementToRecieveFocus;
186
+
187
+ // If a selector has been provided, attempt to find that element
188
+ if (this.config.focusOnDismissSelector) {
189
+ $elementToRecieveFocus = document.querySelector(
190
+ this.config.focusOnDismissSelector
191
+ );
192
+ }
193
+
194
+ // Is the next sibling another alert
195
+ if (!$elementToRecieveFocus) {
196
+ const $nextSibling = this.$module.nextElementSibling;
197
+ if ($nextSibling && $nextSibling.matches('.moj-alert')) {
198
+ $elementToRecieveFocus = $nextSibling;
199
+ }
200
+ }
166
201
 
167
- case 'number':
168
- output = Number(trimmedValue)
169
- break
202
+ // Else try to find any preceding sibling alert or heading
203
+ if (!$elementToRecieveFocus) {
204
+ $elementToRecieveFocus = getPreviousSibling(
205
+ this.$module,
206
+ '.moj-alert, h1, h2, h3, h4, h5, h6'
207
+ );
208
+ }
170
209
 
171
- default:
172
- output = value
173
- }
210
+ // Else find the closest ancestor heading, or fallback to main, or last resort
211
+ // use the body element
212
+ if (!$elementToRecieveFocus) {
213
+ $elementToRecieveFocus = findNearestMatchingElement(
214
+ this.$module,
215
+ 'h1, h2, h3, h4, h5, h6, main, body'
216
+ );
217
+ }
174
218
 
175
- return output
176
- }
177
-
178
- /**
179
- * Parse dataset
180
- *
181
- * Loop over an object and normalise each value using {@link normaliseString},
182
- * optionally expanding nested `i18n.field`
183
- *
184
- * @param {Schema} schema - component schema
185
- * @param {DOMStringMap} dataset - HTML element dataset
186
- * @returns {object} Normalised dataset
187
- */
188
- MOJFrontend.Alert.prototype.parseDataset = function (schema, dataset) {
189
- const parsed = {}
190
-
191
- for (const [field, property] of Object.entries(schema.properties)) {
192
- if (field in dataset) {
193
- if (dataset[field]) {
194
- parsed[field] = this.normaliseString(dataset[field], property)
219
+ // If we have an element, place focus on it
220
+ if ($elementToRecieveFocus) {
221
+ setFocus($elementToRecieveFocus);
222
+ }
223
+
224
+ // Remove the alert
225
+ this.$module.remove();
226
+ };
227
+
228
+ /**
229
+ * Normalise string
230
+ *
231
+ * 'If it looks like a duck, and it quacks like a duck…' 🦆
232
+ *
233
+ * If the passed value looks like a boolean or a number, convert it to a boolean
234
+ * or number.
235
+ *
236
+ * Designed to be used to convert config passed via data attributes (which are
237
+ * always strings) into something sensible.
238
+ *
239
+ * @internal
240
+ * @param {DOMStringMap[string]} value - The value to normalise
241
+ * @param {SchemaProperty} [property] - Component schema property
242
+ * @returns {string | boolean | number | undefined} Normalised data
243
+ */
244
+ Alert.prototype.normaliseString = function (value, property) {
245
+ const trimmedValue = value ? value.trim() : '';
246
+
247
+ let output;
248
+ let outputType;
249
+ if (property && property.type) {
250
+ outputType = property.type;
251
+ }
252
+
253
+ // No schema type set? Determine automatically
254
+ if (!outputType) {
255
+ if (['true', 'false'].includes(trimmedValue)) {
256
+ outputType = 'boolean';
257
+ }
258
+
259
+ // Empty / whitespace-only strings are considered finite so we need to check
260
+ // the length of the trimmed string as well
261
+ if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
262
+ outputType = 'number';
195
263
  }
196
264
  }
197
- }
198
265
 
199
- return parsed
200
- }
201
-
202
- /**
203
- * Config merging function
204
- *
205
- * Takes any number of objects and combines them together, with
206
- * greatest priority on the LAST item passed in.
207
- *
208
- * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
209
- * @returns {{ [key: string]: unknown }} A merged config object
210
- */
211
- MOJFrontend.Alert.prototype.mergeConfigs = function (...configObjects) {
212
- const formattedConfigObject = {}
213
-
214
- // Loop through each of the passed objects
215
- for (const configObject of configObjects) {
216
- for (const key of Object.keys(configObject)) {
217
- const option = formattedConfigObject[key]
218
- const override = configObject[key]
219
-
220
- // Push their keys one-by-one into formattedConfigObject. Any duplicate
221
- // keys with object values will be merged, otherwise the new value will
222
- // override the existing value.
223
- if (typeof option === 'object' && typeof override === 'object') {
224
- // @ts-expect-error Index signature for type 'string' is missing
225
- formattedConfigObject[key] = this.mergeConfigs(option, override)
226
- } else {
227
- formattedConfigObject[key] = override
266
+ switch (outputType) {
267
+ case 'boolean':
268
+ output = trimmedValue === 'true';
269
+ break
270
+
271
+ case 'number':
272
+ output = Number(trimmedValue);
273
+ break
274
+
275
+ default:
276
+ output = value;
277
+ }
278
+
279
+ return output
280
+ };
281
+
282
+ /**
283
+ * Parse dataset
284
+ *
285
+ * Loop over an object and normalise each value using {@link normaliseString},
286
+ * optionally expanding nested `i18n.field`
287
+ *
288
+ * @param {Schema} schema - component schema
289
+ * @param {DOMStringMap} dataset - HTML element dataset
290
+ * @returns {object} Normalised dataset
291
+ */
292
+ Alert.prototype.parseDataset = function (schema, dataset) {
293
+ const parsed = {};
294
+
295
+ for (const [field, property] of Object.entries(schema.properties)) {
296
+ if (field in dataset) {
297
+ if (dataset[field]) {
298
+ parsed[field] = this.normaliseString(dataset[field], property);
299
+ }
300
+ }
301
+ }
302
+
303
+ return parsed
304
+ };
305
+
306
+ /**
307
+ * Config merging function
308
+ *
309
+ * Takes any number of objects and combines them together, with
310
+ * greatest priority on the LAST item passed in.
311
+ *
312
+ * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
313
+ * @returns {{ [key: string]: unknown }} A merged config object
314
+ */
315
+ Alert.prototype.mergeConfigs = function (...configObjects) {
316
+ const formattedConfigObject = {};
317
+
318
+ // Loop through each of the passed objects
319
+ for (const configObject of configObjects) {
320
+ for (const key of Object.keys(configObject)) {
321
+ const option = formattedConfigObject[key];
322
+ const override = configObject[key];
323
+
324
+ // Push their keys one-by-one into formattedConfigObject. Any duplicate
325
+ // keys with object values will be merged, otherwise the new value will
326
+ // override the existing value.
327
+ if (typeof option === 'object' && typeof override === 'object') {
328
+ // @ts-expect-error Index signature for type 'string' is missing
329
+ formattedConfigObject[key] = this.mergeConfigs(option, override);
330
+ } else {
331
+ formattedConfigObject[key] = override;
332
+ }
228
333
  }
229
334
  }
230
- }
231
335
 
232
- return formattedConfigObject
233
- }
234
-
235
- /**
236
- * Schema for component config
237
- *
238
- * @typedef {object} Schema
239
- * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
240
- */
241
-
242
- /**
243
- * Schema property for component config
244
- *
245
- * @typedef {object} SchemaProperty
246
- * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
247
- */
336
+ return formattedConfigObject
337
+ };
338
+
339
+ /**
340
+ * Schema for component config
341
+ *
342
+ * @typedef {object} Schema
343
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
344
+ */
345
+
346
+ /**
347
+ * Schema property for component config
348
+ *
349
+ * @typedef {object} SchemaProperty
350
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
351
+ */
352
+
353
+ exports.Alert = Alert;
354
+
355
+ }));