@ministryofjustice/frontend 4.0.1 → 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 (256) hide show
  1. package/govuk-prototype-kit.config.json +19 -4
  2. package/moj/_base.scss +2 -0
  3. package/moj/_base.scss.map +1 -0
  4. package/moj/all.bundle.js +3010 -0
  5. package/moj/all.bundle.js.map +1 -0
  6. package/moj/all.bundle.mjs +3293 -0
  7. package/moj/all.bundle.mjs.map +1 -0
  8. package/moj/all.mjs +17 -110
  9. package/moj/all.mjs.map +1 -1
  10. package/moj/all.scss +3 -0
  11. package/moj/all.scss.map +1 -0
  12. package/moj/common/index.mjs +57 -0
  13. package/moj/common/index.mjs.map +1 -0
  14. package/moj/common/moj-frontend-version.mjs +14 -0
  15. package/moj/common/moj-frontend-version.mjs.map +1 -0
  16. package/moj/components/_all.scss +2 -0
  17. package/moj/components/_all.scss.map +1 -0
  18. package/moj/components/action-bar/_action-bar.scss +2 -0
  19. package/moj/components/action-bar/_action-bar.scss.map +1 -0
  20. package/moj/components/add-another/_add-another.scss +2 -0
  21. package/moj/components/add-another/_add-another.scss.map +1 -0
  22. package/moj/components/add-another/add-another.bundle.js +157 -0
  23. package/moj/components/add-another/add-another.bundle.js.map +1 -0
  24. package/moj/components/add-another/add-another.bundle.mjs +271 -0
  25. package/moj/components/add-another/add-another.bundle.mjs.map +1 -0
  26. package/moj/components/add-another/add-another.mjs +135 -91
  27. package/moj/components/add-another/add-another.mjs.map +1 -1
  28. package/moj/components/alert/_alert.scss +4 -0
  29. package/moj/components/alert/_alert.scss.map +1 -0
  30. package/moj/components/alert/alert.bundle.js +254 -0
  31. package/moj/components/alert/alert.bundle.js.map +1 -0
  32. package/moj/components/alert/alert.bundle.mjs +490 -0
  33. package/moj/components/alert/alert.bundle.mjs.map +1 -0
  34. package/moj/components/alert/alert.mjs +97 -218
  35. package/moj/components/alert/alert.mjs.map +1 -1
  36. package/moj/components/alert/{alert.spec.helper.js → alert.spec.helper.bundle.js} +1 -1
  37. package/moj/components/alert/alert.spec.helper.bundle.js.map +1 -0
  38. package/moj/components/alert/alert.spec.helper.bundle.mjs +67 -0
  39. package/moj/components/alert/alert.spec.helper.bundle.mjs.map +1 -0
  40. package/moj/components/alert/alert.spec.helper.mjs.map +1 -1
  41. package/moj/components/badge/_badge.scss +2 -0
  42. package/moj/components/badge/_badge.scss.map +1 -0
  43. package/moj/components/banner/_banner.scss +2 -0
  44. package/moj/components/banner/_banner.scss.map +1 -0
  45. package/moj/components/button-menu/README.md +12 -6
  46. package/moj/components/button-menu/_button-menu.scss +4 -1
  47. package/moj/components/button-menu/_button-menu.scss.map +1 -0
  48. package/moj/components/button-menu/button-menu.bundle.js +270 -0
  49. package/moj/components/button-menu/button-menu.bundle.js.map +1 -0
  50. package/moj/components/button-menu/button-menu.bundle.mjs +506 -0
  51. package/moj/components/button-menu/button-menu.bundle.mjs.map +1 -0
  52. package/moj/components/button-menu/button-menu.mjs +214 -280
  53. package/moj/components/button-menu/button-menu.mjs.map +1 -1
  54. package/moj/components/cookie-banner/_cookie-banner.scss +2 -0
  55. package/moj/components/cookie-banner/_cookie-banner.scss.map +1 -0
  56. package/moj/components/currency-input/_currency-input.scss +2 -0
  57. package/moj/components/currency-input/_currency-input.scss.map +1 -0
  58. package/moj/components/date-picker/_date-picker.scss +2 -0
  59. package/moj/components/date-picker/_date-picker.scss.map +1 -0
  60. package/moj/components/date-picker/date-picker.bundle.js +804 -0
  61. package/moj/components/date-picker/date-picker.bundle.js.map +1 -0
  62. package/moj/components/date-picker/date-picker.bundle.mjs +1040 -0
  63. package/moj/components/date-picker/date-picker.bundle.mjs.map +1 -0
  64. package/moj/components/date-picker/date-picker.mjs +663 -827
  65. package/moj/components/date-picker/date-picker.mjs.map +1 -1
  66. package/moj/components/filter/_filter.scss +2 -0
  67. package/moj/components/filter/_filter.scss.map +1 -0
  68. package/moj/components/filter/template.njk +1 -1
  69. package/moj/components/filter-toggle-button/filter-toggle-button.bundle.js +185 -0
  70. package/moj/components/filter-toggle-button/filter-toggle-button.bundle.js.map +1 -0
  71. package/moj/components/filter-toggle-button/filter-toggle-button.bundle.mjs +421 -0
  72. package/moj/components/filter-toggle-button/filter-toggle-button.bundle.mjs.map +1 -0
  73. package/moj/components/filter-toggle-button/filter-toggle-button.mjs +166 -81
  74. package/moj/components/filter-toggle-button/filter-toggle-button.mjs.map +1 -1
  75. package/moj/components/form-validator/form-validator.bundle.js +288 -0
  76. package/moj/components/form-validator/form-validator.bundle.js.map +1 -0
  77. package/moj/components/form-validator/form-validator.bundle.mjs +524 -0
  78. package/moj/components/form-validator/form-validator.bundle.mjs.map +1 -0
  79. package/moj/components/form-validator/form-validator.mjs +226 -149
  80. package/moj/components/form-validator/form-validator.mjs.map +1 -1
  81. package/moj/components/header/_header.scss +2 -0
  82. package/moj/components/header/_header.scss.map +1 -0
  83. package/moj/components/identity-bar/_identity-bar.scss +2 -0
  84. package/moj/components/identity-bar/_identity-bar.scss.map +1 -0
  85. package/moj/components/interruption-card/_interruption-card.scss +2 -0
  86. package/moj/components/interruption-card/_interruption-card.scss.map +1 -0
  87. package/moj/components/messages/_messages.scss +2 -0
  88. package/moj/components/messages/_messages.scss.map +1 -0
  89. package/moj/components/multi-file-upload/_multi-file-upload.scss +2 -0
  90. package/moj/components/multi-file-upload/_multi-file-upload.scss.map +1 -0
  91. package/moj/components/multi-file-upload/multi-file-upload.bundle.js +397 -0
  92. package/moj/components/multi-file-upload/multi-file-upload.bundle.js.map +1 -0
  93. package/moj/components/multi-file-upload/multi-file-upload.bundle.mjs +633 -0
  94. package/moj/components/multi-file-upload/multi-file-upload.bundle.mjs.map +1 -0
  95. package/moj/components/multi-file-upload/multi-file-upload.mjs +384 -213
  96. package/moj/components/multi-file-upload/multi-file-upload.mjs.map +1 -1
  97. package/moj/components/multi-file-upload/template.njk +1 -1
  98. package/moj/components/multi-select/_multi-select.scss +2 -0
  99. package/moj/components/multi-select/_multi-select.scss.map +1 -0
  100. package/moj/components/multi-select/multi-select.bundle.js +143 -0
  101. package/moj/components/multi-select/multi-select.bundle.js.map +1 -0
  102. package/moj/components/multi-select/multi-select.bundle.mjs +379 -0
  103. package/moj/components/multi-select/multi-select.bundle.mjs.map +1 -0
  104. package/moj/components/multi-select/multi-select.mjs +123 -64
  105. package/moj/components/multi-select/multi-select.mjs.map +1 -1
  106. package/moj/components/notification-badge/_notification-badge.scss +2 -0
  107. package/moj/components/notification-badge/_notification-badge.scss.map +1 -0
  108. package/moj/components/organisation-switcher/_organisation-switcher.scss +2 -0
  109. package/moj/components/organisation-switcher/_organisation-switcher.scss.map +1 -0
  110. package/moj/components/page-header-actions/_page-header-actions.scss +2 -0
  111. package/moj/components/page-header-actions/_page-header-actions.scss.map +1 -0
  112. package/moj/components/pagination/_pagination.scss +2 -2
  113. package/moj/components/pagination/_pagination.scss.map +1 -0
  114. package/moj/components/password-reveal/_password-reveal.scss +5 -1
  115. package/moj/components/password-reveal/_password-reveal.scss.map +1 -0
  116. package/moj/components/password-reveal/password-reveal.bundle.js +52 -0
  117. package/moj/components/password-reveal/password-reveal.bundle.js.map +1 -0
  118. package/moj/components/password-reveal/password-reveal.bundle.mjs +166 -0
  119. package/moj/components/password-reveal/password-reveal.bundle.mjs.map +1 -0
  120. package/moj/components/password-reveal/password-reveal.mjs +39 -29
  121. package/moj/components/password-reveal/password-reveal.mjs.map +1 -1
  122. package/moj/components/primary-navigation/_primary-navigation.scss +2 -0
  123. package/moj/components/primary-navigation/_primary-navigation.scss.map +1 -0
  124. package/moj/components/progress-bar/_progress-bar.scss +2 -0
  125. package/moj/components/progress-bar/_progress-bar.scss.map +1 -0
  126. package/moj/components/rich-text-editor/README.md +16 -9
  127. package/moj/components/rich-text-editor/_rich-text-editor.scss +2 -0
  128. package/moj/components/rich-text-editor/_rich-text-editor.scss.map +1 -0
  129. package/moj/components/rich-text-editor/rich-text-editor.bundle.js +210 -0
  130. package/moj/components/rich-text-editor/rich-text-editor.bundle.js.map +1 -0
  131. package/moj/components/rich-text-editor/rich-text-editor.bundle.mjs +446 -0
  132. package/moj/components/rich-text-editor/rich-text-editor.bundle.mjs.map +1 -0
  133. package/moj/components/rich-text-editor/rich-text-editor.mjs +186 -140
  134. package/moj/components/rich-text-editor/rich-text-editor.mjs.map +1 -1
  135. package/moj/components/search/_search.scss +2 -0
  136. package/moj/components/search/_search.scss.map +1 -0
  137. package/moj/components/search-toggle/{search-toggle.scss → _search-toggle.scss} +2 -0
  138. package/moj/components/search-toggle/_search-toggle.scss.map +1 -0
  139. package/moj/components/search-toggle/search-toggle.bundle.js +122 -0
  140. package/moj/components/search-toggle/search-toggle.bundle.js.map +1 -0
  141. package/moj/components/search-toggle/search-toggle.bundle.mjs +358 -0
  142. package/moj/components/search-toggle/search-toggle.bundle.mjs.map +1 -0
  143. package/moj/components/search-toggle/search-toggle.mjs +104 -43
  144. package/moj/components/search-toggle/search-toggle.mjs.map +1 -1
  145. package/moj/components/side-navigation/_side-navigation.scss +2 -0
  146. package/moj/components/side-navigation/_side-navigation.scss.map +1 -0
  147. package/moj/components/sortable-table/_sortable-table.scss +2 -2
  148. package/moj/components/sortable-table/_sortable-table.scss.map +1 -0
  149. package/moj/components/sortable-table/sortable-table.bundle.js +202 -0
  150. package/moj/components/sortable-table/sortable-table.bundle.js.map +1 -0
  151. package/moj/components/sortable-table/sortable-table.bundle.mjs +438 -0
  152. package/moj/components/sortable-table/sortable-table.bundle.mjs.map +1 -0
  153. package/moj/components/sortable-table/sortable-table.mjs +179 -122
  154. package/moj/components/sortable-table/sortable-table.mjs.map +1 -1
  155. package/moj/components/sub-navigation/_sub-navigation.scss +2 -0
  156. package/moj/components/sub-navigation/_sub-navigation.scss.map +1 -0
  157. package/moj/components/tag/_tag.scss +2 -0
  158. package/moj/components/tag/_tag.scss.map +1 -0
  159. package/moj/components/task-list/_task-list.scss +2 -0
  160. package/moj/components/task-list/_task-list.scss.map +1 -0
  161. package/moj/components/ticket-panel/_ticket-panel.scss +2 -0
  162. package/moj/components/ticket-panel/_ticket-panel.scss.map +1 -0
  163. package/moj/components/timeline/_timeline.scss +2 -0
  164. package/moj/components/timeline/_timeline.scss.map +1 -0
  165. package/moj/core/_all.scss +3 -0
  166. package/moj/core/_all.scss.map +1 -0
  167. package/moj/core/_moj-frontend-properties.scss +7 -0
  168. package/moj/core/_moj-frontend-properties.scss.map +1 -0
  169. package/moj/filters/all.js +44 -22
  170. package/moj/filters/prototype-kit-13-filters.js +4 -3
  171. package/moj/helpers/_all.scss +2 -0
  172. package/moj/helpers/_all.scss.map +1 -0
  173. package/moj/helpers/_hidden.scss +2 -0
  174. package/moj/helpers/_hidden.scss.map +1 -0
  175. package/moj/helpers/_links.scss +2 -0
  176. package/moj/helpers/_links.scss.map +1 -0
  177. package/moj/helpers.bundle.js +140 -0
  178. package/moj/helpers.bundle.js.map +1 -0
  179. package/moj/helpers.bundle.mjs +128 -0
  180. package/moj/helpers.bundle.mjs.map +1 -0
  181. package/moj/helpers.mjs +50 -77
  182. package/moj/helpers.mjs.map +1 -1
  183. package/moj/init.js +11 -2
  184. package/moj/moj-frontend.min.css +1 -1
  185. package/moj/moj-frontend.min.css.map +1 -1
  186. package/moj/moj-frontend.min.js +1 -1
  187. package/moj/moj-frontend.min.js.map +1 -1
  188. package/moj/objects/_all.scss +2 -0
  189. package/moj/objects/_all.scss.map +1 -0
  190. package/moj/objects/_button-group.scss +2 -0
  191. package/moj/objects/_button-group.scss.map +1 -0
  192. package/moj/objects/_filter-layout.scss +2 -0
  193. package/moj/objects/_filter-layout.scss.map +1 -0
  194. package/moj/objects/_scrollable-pane.scss +2 -0
  195. package/moj/objects/_scrollable-pane.scss.map +1 -0
  196. package/moj/objects/_width-container.scss +2 -0
  197. package/moj/objects/_width-container.scss.map +1 -0
  198. package/moj/settings/_all.scss +2 -0
  199. package/moj/settings/_all.scss.map +1 -0
  200. package/moj/settings/_assets.scss +2 -0
  201. package/moj/settings/_assets.scss.map +1 -0
  202. package/moj/settings/_colours.scss +2 -0
  203. package/moj/settings/_colours.scss.map +1 -0
  204. package/moj/settings/_measurements.scss +2 -0
  205. package/moj/settings/_measurements.scss.map +1 -0
  206. package/moj/settings/_typography.scss +2 -0
  207. package/moj/settings/_typography.scss.map +1 -0
  208. package/moj/template.njk +13 -0
  209. package/moj/utilities/_all.scss +2 -0
  210. package/moj/utilities/_all.scss.map +1 -0
  211. package/moj/utilities/_hidden.scss +2 -0
  212. package/moj/utilities/_hidden.scss.map +1 -0
  213. package/moj/utilities/_width-container.scss +2 -0
  214. package/moj/utilities/_width-container.scss.map +1 -0
  215. package/moj/vendor/govuk-frontend/_base.scss +2 -0
  216. package/moj/vendor/govuk-frontend/_base.scss.map +1 -0
  217. package/moj/vendor/govuk-frontend/_index.scss +2 -0
  218. package/moj/vendor/govuk-frontend/_index.scss.map +1 -0
  219. package/package.json +5 -6
  220. package/moj/all.jquery.min.js +0 -1
  221. package/moj/all.jquery.min.js.map +0 -1
  222. package/moj/all.js +0 -2662
  223. package/moj/all.js.map +0 -1
  224. package/moj/components/add-another/add-another.js +0 -115
  225. package/moj/components/add-another/add-another.js.map +0 -1
  226. package/moj/components/alert/alert.js +0 -356
  227. package/moj/components/alert/alert.js.map +0 -1
  228. package/moj/components/alert/alert.spec.helper.js.map +0 -1
  229. package/moj/components/button-menu/button-menu.js +0 -338
  230. package/moj/components/button-menu/button-menu.js.map +0 -1
  231. package/moj/components/date-picker/date-picker.js +0 -970
  232. package/moj/components/date-picker/date-picker.js.map +0 -1
  233. package/moj/components/filter-toggle-button/filter-toggle-button.js +0 -102
  234. package/moj/components/filter-toggle-button/filter-toggle-button.js.map +0 -1
  235. package/moj/components/form-validator/form-validator.js +0 -205
  236. package/moj/components/form-validator/form-validator.js.map +0 -1
  237. package/moj/components/multi-file-upload/multi-file-upload.js +0 -241
  238. package/moj/components/multi-file-upload/multi-file-upload.js.map +0 -1
  239. package/moj/components/multi-select/multi-select.js +0 -86
  240. package/moj/components/multi-select/multi-select.js.map +0 -1
  241. package/moj/components/password-reveal/password-reveal.js +0 -44
  242. package/moj/components/password-reveal/password-reveal.js.map +0 -1
  243. package/moj/components/rich-text-editor/rich-text-editor.js +0 -166
  244. package/moj/components/rich-text-editor/rich-text-editor.js.map +0 -1
  245. package/moj/components/search-toggle/search-toggle.js +0 -63
  246. package/moj/components/search-toggle/search-toggle.js.map +0 -1
  247. package/moj/components/sortable-table/sortable-table.js +0 -147
  248. package/moj/components/sortable-table/sortable-table.js.map +0 -1
  249. package/moj/helpers.js +0 -200
  250. package/moj/helpers.js.map +0 -1
  251. package/moj/vendor/html5shiv.js +0 -326
  252. package/moj/vendor/jquery.js +0 -9300
  253. package/moj/version.js +0 -12
  254. package/moj/version.js.map +0 -1
  255. package/moj/version.mjs +0 -4
  256. package/moj/version.mjs.map +0 -1
@@ -0,0 +1,3293 @@
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;
301
+ }
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)) {
328
+ return;
329
+ }
330
+ $button.type = 'button';
331
+ });
332
+ }
333
+
334
+ /**
335
+ * @param {MouseEvent} event - Click event
336
+ */
337
+ onAddButtonClick(event) {
338
+ const $button = event.target;
339
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-add-another__add-button')) {
340
+ return;
341
+ }
342
+ const $items = this.getItems();
343
+ const $item = this.getNewItem();
344
+ if (!$item || !($item instanceof HTMLElement)) {
345
+ return;
346
+ }
347
+ this.updateAttributes($item, $items.length);
348
+ this.resetItem($item);
349
+ const $firstItem = $items[0];
350
+ if (!this.hasRemoveButton($firstItem)) {
351
+ this.createRemoveButton($firstItem);
352
+ }
353
+ $items[$items.length - 1].after($item);
354
+ const $input = $item.querySelector('input, textarea, select');
355
+ if ($input && $input instanceof HTMLInputElement) {
356
+ $input.focus();
357
+ }
358
+ }
359
+
360
+ /**
361
+ * @param {HTMLElement} $item - Add another item
362
+ */
363
+ hasRemoveButton($item) {
364
+ return $item.querySelectorAll('.moj-add-another__remove-button').length;
365
+ }
366
+ getItems() {
367
+ if (!this.$root) {
368
+ return [];
369
+ }
370
+ const $items = Array.from(this.$root.querySelectorAll('.moj-add-another__item'));
371
+ return $items.filter(item => item instanceof HTMLElement);
372
+ }
373
+ getNewItem() {
374
+ const $items = this.getItems();
375
+ const $item = $items[0].cloneNode(true);
376
+ if (!$item || !($item instanceof HTMLElement)) {
377
+ return;
378
+ }
379
+ if (!this.hasRemoveButton($item)) {
380
+ this.createRemoveButton($item);
381
+ }
382
+ return $item;
383
+ }
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)) {
392
+ return;
393
+ }
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;
402
+ }
403
+ });
404
+ }
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);
415
+ }
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)) {
423
+ return;
424
+ }
425
+ if ($input.type === 'checkbox' || $input.type === 'radio') {
426
+ $input.checked = false;
427
+ } else {
428
+ $input.value = '';
429
+ }
430
+ });
431
+ }
432
+
433
+ /**
434
+ * @param {MouseEvent} event - Click event
435
+ */
436
+ onRemoveButtonClick(event) {
437
+ const $button = event.target;
438
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-add-another__remove-button')) {
439
+ return;
440
+ }
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();
445
+ }
446
+ $items.forEach(($item, index) => {
447
+ this.updateAttributes($item, index);
448
+ });
449
+ this.focusHeading();
450
+ }
451
+ focusHeading() {
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');
506
+ }
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();
517
+ }
518
+
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) {
525
+ let re, m;
526
+ if ($element.getAttribute(attr)) {
527
+ if ($element.getAttribute(attr) === value) {
528
+ $element.removeAttribute(attr);
529
+ } else {
530
+ re = new RegExp(`(^|\\s)${value}(\\s|$)`);
531
+ m = $element.getAttribute(attr).match(re);
532
+ if (m && m.length === 3) {
533
+ $element.setAttribute(attr, $element.getAttribute(attr).replace(re, m[1] && m[2] ? ' ' : ''));
534
+ }
535
+ }
536
+ }
537
+ }
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) {
545
+ let re;
546
+ if (!$element.getAttribute(attr)) {
547
+ $element.setAttribute(attr, value);
548
+ } else {
549
+ re = new RegExp(`(^|\\s)${value}(\\s|$)`);
550
+ if (!re.test($element.getAttribute(attr))) {
551
+ $element.setAttribute(attr, `${$element.getAttribute(attr)} ${value}`);
552
+ }
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Find an elements preceding sibling
558
+ *
559
+ * Utility function to find an elements previous sibling matching the provided
560
+ * selector.
561
+ *
562
+ * @param {Element | null} $element - Element to find siblings for
563
+ * @param {string} [selector] - selector for required sibling
564
+ */
565
+ function getPreviousSibling($element, selector) {
566
+ if (!$element || !($element instanceof HTMLElement)) {
567
+ return;
568
+ }
569
+
570
+ // Get the previous sibling element
571
+ let $sibling = $element.previousElementSibling;
572
+
573
+ // If the sibling matches our selector, use it
574
+ // If not, jump to the next sibling and continue the loop
575
+ while ($sibling) {
576
+ if ($sibling.matches(selector)) return $sibling;
577
+ $sibling = $sibling.previousElementSibling;
578
+ }
579
+ }
580
+
581
+ /**
582
+ * @param {Element | null} $element
583
+ * @param {string} [selector]
584
+ */
585
+ function findNearestMatchingElement($element, selector) {
586
+ // If no element or selector is provided, return
587
+ if (!$element || !($element instanceof HTMLElement) || false) {
588
+ return;
589
+ }
590
+
591
+ // Start with the current element
592
+ let $currentElement = $element;
593
+ while ($currentElement) {
594
+ // First check the current element
595
+ if ($currentElement.matches(selector)) {
596
+ return $currentElement;
597
+ }
598
+
599
+ // Check all previous siblings
600
+ let $sibling = $currentElement.previousElementSibling;
601
+ while ($sibling) {
602
+ // Check if the sibling itself is a heading
603
+ if ($sibling.matches(selector)) {
604
+ return $sibling;
605
+ }
606
+ $sibling = $sibling.previousElementSibling;
607
+ }
608
+
609
+ // If no match found in siblings, move up to parent
610
+ $currentElement = $currentElement.parentElement;
611
+ }
612
+ }
613
+
614
+ /**
615
+ * @augments {ConfigurableComponent<AlertConfig>}
616
+ */
617
+ class Alert extends ConfigurableComponent {
618
+ /**
619
+ * @param {Element | null} $root - HTML element to use for alert
620
+ * @param {AlertConfig} [config] - Alert config
621
+ */
622
+ constructor($root, config = {}) {
623
+ super($root, config);
624
+
625
+ /**
626
+ * Focus the alert
627
+ *
628
+ * If `role="alert"` is set, focus the element to help some assistive
629
+ * technologies prioritise announcing it.
630
+ *
631
+ * You can turn off the auto-focus functionality by setting
632
+ * `data-disable-auto-focus="true"` in the component HTML. You might wish to
633
+ * do this based on user research findings, or to avoid a clash with another
634
+ * element which should be focused when the page loads.
635
+ */
636
+ if (this.$root.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
637
+ setFocus(this.$root);
638
+ }
639
+ this.$dismissButton = this.$root.querySelector('.moj-alert__dismiss');
640
+ if (this.config.dismissible && this.$dismissButton) {
641
+ this.$dismissButton.innerHTML = this.config.dismissText;
642
+ this.$dismissButton.removeAttribute('hidden');
643
+ this.$root.addEventListener('click', event => {
644
+ if (event.target instanceof Node && this.$dismissButton.contains(event.target)) {
645
+ this.dimiss();
646
+ }
647
+ });
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Handle dismissing the alert
653
+ */
654
+ dimiss() {
655
+ let $elementToRecieveFocus;
656
+
657
+ // If a selector has been provided, attempt to find that element
658
+ if (this.config.focusOnDismissSelector) {
659
+ $elementToRecieveFocus = document.querySelector(this.config.focusOnDismissSelector);
660
+ }
661
+
662
+ // Is the next sibling another alert
663
+ if (!$elementToRecieveFocus) {
664
+ const $nextSibling = this.$root.nextElementSibling;
665
+ if ($nextSibling && $nextSibling.matches('.moj-alert')) {
666
+ $elementToRecieveFocus = $nextSibling;
667
+ }
668
+ }
669
+
670
+ // Else try to find any preceding sibling alert or heading
671
+ if (!$elementToRecieveFocus) {
672
+ $elementToRecieveFocus = getPreviousSibling(this.$root, '.moj-alert, h1, h2, h3, h4, h5, h6');
673
+ }
674
+
675
+ // Else find the closest ancestor heading, or fallback to main, or last resort
676
+ // use the body element
677
+ if (!$elementToRecieveFocus) {
678
+ $elementToRecieveFocus = findNearestMatchingElement(this.$root, 'h1, h2, h3, h4, h5, h6, main, body');
679
+ }
680
+
681
+ // If we have an element, place focus on it
682
+ if ($elementToRecieveFocus instanceof HTMLElement) {
683
+ setFocus($elementToRecieveFocus);
684
+ }
685
+
686
+ // Remove the alert
687
+ this.$root.remove();
688
+ }
689
+
690
+ /**
691
+ * Name for the component used when initialising using data-module attributes.
692
+ */
693
+ }
694
+
695
+ /**
696
+ * @typedef {object} AlertConfig
697
+ * @property {boolean} [dismissible=false] - Can the alert be dismissed by the user
698
+ * @property {string} [dismissText=Dismiss] - the label text for the dismiss button
699
+ * @property {boolean} [disableAutoFocus=false] - whether the alert will be autofocused
700
+ * @property {string} [focusOnDismissSelector] - CSS Selector for element to be focused on dismiss
701
+ */
702
+
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 {
743
+ /**
744
+ * @param {Element | null} $root - HTML element to use for button menu
745
+ * @param {ButtonMenuConfig} [config] - Button menu config
746
+ */
747
+ constructor($root, config = {}) {
748
+ super($root, config);
749
+
750
+ // If only one button is provided, don't initiate a menu and toggle button
751
+ // if classes have been provided for the toggleButton, apply them to the single item
752
+ if (this.$root.children.length === 1) {
753
+ const $button = this.$root.children[0];
754
+ $button.classList.forEach(className => {
755
+ if (className.startsWith('govuk-button-')) {
756
+ $button.classList.remove(className);
757
+ }
758
+ $button.classList.remove('moj-button-menu__item');
759
+ $button.classList.add('moj-button-menu__single-button');
760
+ });
761
+ if (this.config.buttonClasses) {
762
+ $button.classList.add(...this.config.buttonClasses.split(' '));
763
+ }
764
+ }
765
+ // Otherwise initialise a button menu
766
+ if (this.$root.children.length > 1) {
767
+ this.initMenu();
768
+ }
769
+ }
770
+ initMenu() {
771
+ this.$menu = this.createMenu();
772
+ this.$root.insertAdjacentHTML('afterbegin', this.toggleTemplate());
773
+ this.setupMenuItems();
774
+ this.$menuToggle = this.$root.querySelector(':scope > button');
775
+ this.$items = this.$menu.querySelectorAll('a, button');
776
+ this.$menuToggle.addEventListener('click', event => {
777
+ this.toggleMenu(event);
778
+ });
779
+ this.$root.addEventListener('keydown', event => {
780
+ this.handleKeyDown(event);
781
+ });
782
+ document.addEventListener('click', event => {
783
+ if (event.target instanceof Node && !this.$root.contains(event.target)) {
784
+ this.closeMenu(false);
785
+ }
786
+ });
787
+ }
788
+ createMenu() {
789
+ const $menu = document.createElement('ul');
790
+ $menu.setAttribute('role', 'list');
791
+ $menu.hidden = true;
792
+ $menu.classList.add('moj-button-menu__wrapper');
793
+ if (this.config.alignMenu === 'right') {
794
+ $menu.classList.add('moj-button-menu__wrapper--right');
795
+ }
796
+ this.$root.appendChild($menu);
797
+ while (this.$root.firstChild !== $menu) {
798
+ $menu.appendChild(this.$root.firstChild);
799
+ }
800
+ return $menu;
801
+ }
802
+ setupMenuItems() {
803
+ Array.from(this.$menu.children).forEach($menuItem => {
804
+ // wrap item in li tag
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');
811
+ }
812
+ $menuItem.classList.forEach(className => {
813
+ if (className.startsWith('govuk-button')) {
814
+ $menuItem.classList.remove(className);
815
+ }
816
+ });
817
+
818
+ // add a slight delay after click before closing the menu, makes it *feel* better
819
+ $menuItem.addEventListener('click', () => {
820
+ setTimeout(() => {
821
+ this.closeMenu(false);
822
+ }, 50);
823
+ });
824
+ });
825
+ }
826
+ toggleTemplate() {
827
+ return `
828
+ <button type="button" class="govuk-button moj-button-menu__toggle-button ${this.config.buttonClasses || ''}" aria-haspopup="true" aria-expanded="false">
829
+ <span>
830
+ ${this.config.buttonText}
831
+ <svg width="11" height="5" viewBox="0 0 11 5" xmlns="http://www.w3.org/2000/svg">
832
+ <path d="M5.5 0L11 5L0 5L5.5 0Z" fill="currentColor"/>
833
+ </svg>
834
+ </span>
835
+ </button>`;
836
+ }
837
+
838
+ /**
839
+ * @returns {boolean}
840
+ */
841
+ isOpen() {
842
+ return this.$menuToggle.getAttribute('aria-expanded') === 'true';
843
+ }
844
+
845
+ /**
846
+ * @param {MouseEvent} event - Click event
847
+ */
848
+ toggleMenu(event) {
849
+ event.preventDefault();
850
+
851
+ // If menu is triggered with mouse don't move focus to first item
852
+ const keyboardEvent = event.detail === 0;
853
+ const focusIndex = keyboardEvent ? 0 : -1;
854
+ if (this.isOpen()) {
855
+ this.closeMenu();
856
+ } else {
857
+ this.openMenu(focusIndex);
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Opens the menu and optionally sets the focus to the item with given index
863
+ *
864
+ * @param {number} focusIndex - The index of the item to focus
865
+ */
866
+ openMenu(focusIndex = 0) {
867
+ this.$menu.hidden = false;
868
+ this.$menuToggle.setAttribute('aria-expanded', 'true');
869
+ if (focusIndex !== -1) {
870
+ this.focusItem(focusIndex);
871
+ }
872
+ }
873
+
874
+ /**
875
+ * Closes the menu and optionally returns focus back to menuToggle
876
+ *
877
+ * @param {boolean} moveFocus - whether to return focus to the toggle button
878
+ */
879
+ closeMenu(moveFocus = true) {
880
+ this.$menu.hidden = true;
881
+ this.$menuToggle.setAttribute('aria-expanded', 'false');
882
+ if (moveFocus) {
883
+ this.$menuToggle.focus();
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Focuses the menu item at the specified index
889
+ *
890
+ * @param {number} index - the index of the item to focus
891
+ */
892
+ focusItem(index) {
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();
898
+ }
899
+ }
900
+ currentFocusIndex() {
901
+ const $activeElement = document.activeElement;
902
+ const $menuItems = Array.from(this.$items);
903
+ return ($activeElement instanceof HTMLAnchorElement || $activeElement instanceof HTMLButtonElement) && $menuItems.indexOf($activeElement);
904
+ }
905
+
906
+ /**
907
+ * @param {KeyboardEvent} event - Keydown event
908
+ */
909
+ handleKeyDown(event) {
910
+ if (event.target === this.$menuToggle) {
911
+ switch (event.key) {
912
+ case 'ArrowDown':
913
+ event.preventDefault();
914
+ this.openMenu();
915
+ break;
916
+ case 'ArrowUp':
917
+ event.preventDefault();
918
+ this.openMenu(this.$items.length - 1);
919
+ break;
920
+ }
921
+ }
922
+ if (event.target instanceof Node && this.$menu.contains(event.target) && this.isOpen()) {
923
+ switch (event.key) {
924
+ case 'ArrowDown':
925
+ event.preventDefault();
926
+ if (this.currentFocusIndex() !== -1) {
927
+ this.focusItem(this.currentFocusIndex() + 1);
928
+ }
929
+ break;
930
+ case 'ArrowUp':
931
+ event.preventDefault();
932
+ if (this.currentFocusIndex() !== -1) {
933
+ this.focusItem(this.currentFocusIndex() - 1);
934
+ }
935
+ break;
936
+ case 'Home':
937
+ event.preventDefault();
938
+ this.focusItem(0);
939
+ break;
940
+ case 'End':
941
+ event.preventDefault();
942
+ this.focusItem(this.$items.length - 1);
943
+ break;
944
+ }
945
+ }
946
+ if (event.key === 'Escape' && this.isOpen()) {
947
+ this.closeMenu();
948
+ }
949
+ if (event.key === 'Tab' && this.isOpen()) {
950
+ this.closeMenu(false);
951
+ }
952
+ }
953
+
954
+ /**
955
+ * Name for the component used when initialising using data-module attributes.
956
+ */
957
+ }
958
+
959
+ /**
960
+ * @typedef {object} ButtonMenuConfig
961
+ * @property {string} [buttonText='Actions'] - Label for the toggle button
962
+ * @property {"left" | "right"} [alignMenu='left'] - the alignment of the menu
963
+ * @property {string} [buttonClasses='govuk-button--secondary'] - css classes applied to the toggle button
964
+ */
965
+
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 {
1003
+ /**
1004
+ * @param {Element | null} $root - HTML element to use for date picker
1005
+ * @param {DatePickerConfig} [config] - Date picker config
1006
+ */
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);
1011
+ if (!$input || !($input instanceof HTMLInputElement)) {
1012
+ return this;
1013
+ }
1014
+ this.$input = $input;
1015
+ this.dayLabels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
1016
+ this.monthLabels = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
1017
+ this.currentDate = new Date();
1018
+ this.currentDate.setHours(0, 0, 0, 0);
1019
+ this.calendarDays = /** @type {DSCalendarDay[]} */[];
1020
+ this.excludedDates = /** @type {Date[]} */[];
1021
+ this.excludedDays = /** @type {number[]} */[];
1022
+ this.buttonClass = 'moj-datepicker__button';
1023
+ this.selectedDayButtonClass = 'moj-datepicker__button--selected';
1024
+ this.currentDayButtonClass = 'moj-datepicker__button--current';
1025
+ this.todayButtonClass = 'moj-datepicker__button--today';
1026
+ this.setOptions();
1027
+ this.initControls();
1028
+ }
1029
+ initControls() {
1030
+ this.id = `datepicker-${this.$input.id}`;
1031
+ this.$dialog = this.createDialog();
1032
+ this.createCalendarHeaders();
1033
+ const $componentWrapper = document.createElement('div');
1034
+ const $inputWrapper = document.createElement('div');
1035
+ $componentWrapper.classList.add('moj-datepicker__wrapper');
1036
+ $inputWrapper.classList.add('govuk-input__wrapper');
1037
+ this.$input.parentElement.insertBefore($componentWrapper, this.$input);
1038
+ $componentWrapper.appendChild($inputWrapper);
1039
+ $inputWrapper.appendChild(this.$input);
1040
+ $inputWrapper.insertAdjacentHTML('beforeend', this.toggleTemplate());
1041
+ $componentWrapper.insertAdjacentElement('beforeend', this.$dialog);
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');
1046
+ this.createCalendar();
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');
1059
+
1060
+ // add event listeners
1061
+ this.$prevMonthButton.addEventListener('click', event => this.focusPreviousMonth(event, false));
1062
+ this.$prevYearButton.addEventListener('click', event => this.focusPreviousYear(event, false));
1063
+ this.$nextMonthButton.addEventListener('click', event => this.focusNextMonth(event, false));
1064
+ this.$nextYearButton.addEventListener('click', event => this.focusNextYear(event, false));
1065
+ this.$cancelButton.addEventListener('click', event => {
1066
+ event.preventDefault();
1067
+ this.closeDialog();
1068
+ });
1069
+ this.$okButton.addEventListener('click', () => {
1070
+ this.selectDate(this.currentDate);
1071
+ });
1072
+ const $dialogButtons = this.$dialog.querySelectorAll('button:not([disabled="true"])');
1073
+ this.$firstButtonInDialog = $dialogButtons[0];
1074
+ this.$lastButtonInDialog = $dialogButtons[$dialogButtons.length - 1];
1075
+ this.$firstButtonInDialog.addEventListener('keydown', event => this.firstButtonKeydown(event));
1076
+ this.$lastButtonInDialog.addEventListener('keydown', event => this.lastButtonKeydown(event));
1077
+ this.$calendarButton.addEventListener('click', event => this.toggleDialog(event));
1078
+ this.$dialog.addEventListener('keydown', event => {
1079
+ if (event.key === 'Escape') {
1080
+ this.closeDialog();
1081
+ event.preventDefault();
1082
+ event.stopPropagation();
1083
+ }
1084
+ });
1085
+ document.body.addEventListener('mouseup', event => this.backgroundClick(event));
1086
+
1087
+ // populates calendar with initial dates, avoids Wave errors about null buttons
1088
+ this.updateCalendar();
1089
+ }
1090
+ createDialog() {
1091
+ const titleId = `datepicker-title-${this.$input.id}`;
1092
+ const $dialog = document.createElement('div');
1093
+ $dialog.id = this.id;
1094
+ $dialog.setAttribute('class', 'moj-datepicker__dialog');
1095
+ $dialog.setAttribute('role', 'dialog');
1096
+ $dialog.setAttribute('aria-modal', 'true');
1097
+ $dialog.setAttribute('aria-labelledby', titleId);
1098
+ $dialog.innerHTML = this.dialogTemplate(titleId);
1099
+ $dialog.hidden = true;
1100
+ return $dialog;
1101
+ }
1102
+ createCalendar() {
1103
+ const $tbody = this.$dialog.querySelector('tbody');
1104
+ let dayCount = 0;
1105
+ for (let i = 0; i < 6; i++) {
1106
+ // create row
1107
+ const $row = $tbody.insertRow(i);
1108
+ for (let j = 0; j < 7; j++) {
1109
+ // create cell (day)
1110
+ const $cell = document.createElement('td');
1111
+ const $dateButton = document.createElement('button');
1112
+ $cell.appendChild($dateButton);
1113
+ $row.appendChild($cell);
1114
+ const calendarDay = new DSCalendarDay($dateButton, dayCount, i, j, this);
1115
+ this.calendarDays.push(calendarDay);
1116
+ dayCount++;
1117
+ }
1118
+ }
1119
+ }
1120
+ toggleTemplate() {
1121
+ return `<button class="moj-datepicker__toggle moj-js-datepicker-toggle" type="button" aria-haspopup="dialog" aria-controls="${this.id}" aria-expanded="false">
1122
+ <span class="govuk-visually-hidden">Choose date</span>
1123
+ <svg width="32" height="24" focusable="false" class="moj-datepicker-icon" aria-hidden="true" role="img" viewBox="0 0 22 22">
1124
+ <path
1125
+ fill="currentColor"
1126
+ fill-rule="evenodd"
1127
+ clip-rule="evenodd"
1128
+ d="M16.1333 2.93333H5.86668V4.4C5.86668 5.21002 5.21003 5.86667 4.40002 5.86667C3.59 5.86667 2.93335 5.21002 2.93335 4.4V2.93333H2C0.895431 2.93333 0 3.82877 0 4.93334V19.2667C0 20.3712 0.89543 21.2667 2 21.2667H20C21.1046 21.2667 22 20.3712 22 19.2667V4.93333C22 3.82876 21.1046 2.93333 20 2.93333H19.0667V4.4C19.0667 5.21002 18.41 5.86667 17.6 5.86667C16.79 5.86667 16.1333 5.21002 16.1333 4.4V2.93333ZM20.5333 8.06667H1.46665V18.8C1.46665 19.3523 1.91436 19.8 2.46665 19.8H19.5333C20.0856 19.8 20.5333 19.3523 20.5333 18.8V8.06667Z"
1129
+ ></path>
1130
+ <rect x="3.66669" width="1.46667" height="5.13333" rx="0.733333" fill="currentColor"></rect>
1131
+ <rect x="16.8667" width="1.46667" height="5.13333" rx="0.733333" fill="currentColor"></rect>
1132
+ </svg>
1133
+ </button>`;
1134
+ }
1135
+
1136
+ /**
1137
+ * HTML template for calendar dialog
1138
+ *
1139
+ * @param {string} [titleId] - Id attribute for dialog title
1140
+ * @returns {string}
1141
+ */
1142
+ dialogTemplate(titleId) {
1143
+ return `<div class="moj-datepicker__dialog-header">
1144
+ <div class="moj-datepicker__dialog-navbuttons">
1145
+ <button class="moj-datepicker__button moj-js-datepicker-prev-year">
1146
+ <span class="govuk-visually-hidden">Previous year</span>
1147
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" fill="none" focusable="false" aria-hidden="true" role="img">
1148
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M23.1643 20L28.9572 14.2071L27.5429 12.7929L20.3358 20L27.5429 27.2071L28.9572 25.7929L23.1643 20Z" fill="currentColor"/>
1149
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M17.1643 20L22.9572 14.2071L21.5429 12.7929L14.3358 20L21.5429 27.2071L22.9572 25.7929L17.1643 20Z" fill="currentColor"/>
1150
+ </svg>
1151
+ </button>
1152
+
1153
+ <button class="moj-datepicker__button moj-js-datepicker-prev-month">
1154
+ <span class="govuk-visually-hidden">Previous month</span>
1155
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" focusable="false" aria-hidden="true" role="img">
1156
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M20.5729 20L25.7865 14.2071L24.5137 12.7929L18.0273 20L24.5137 27.2071L25.7865 25.7929L20.5729 20Z" fill="currentColor"/>
1157
+ </svg>
1158
+ </button>
1159
+ </div>
1160
+
1161
+ <h2 id="${titleId}" class="moj-datepicker__dialog-title moj-js-datepicker-month-year" aria-live="polite">June 2020</h2>
1162
+
1163
+ <div class="moj-datepicker__dialog-navbuttons">
1164
+ <button class="moj-datepicker__button moj-js-datepicker-next-month">
1165
+ <span class="govuk-visually-hidden">Next month</span>
1166
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" focusable="false" aria-hidden="true" role="img">
1167
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M23.4271 20L18.2135 14.2071L19.4863 12.7929L25.9727 20L19.4863 27.2071L18.2135 25.7929L23.4271 20Z" fill="currentColor"/>
1168
+ </svg>
1169
+ </button>
1170
+
1171
+ <button class="moj-datepicker__button moj-js-datepicker-next-year">
1172
+ <span class="govuk-visually-hidden">Next year</span>
1173
+ <svg width="44" height="40" viewBox="0 0 44 40" fill="none" fill="none" focusable="false" aria-hidden="true" role="img">
1174
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M20.8357 20L15.0428 14.2071L16.4571 12.7929L23.6642 20L16.4571 27.2071L15.0428 25.7929L20.8357 20Z" fill="currentColor"/>
1175
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M26.8357 20L21.0428 14.2071L22.4571 12.7929L29.6642 20L22.4571 27.2071L21.0428 25.7929L26.8357 20Z" fill="currentColor"/>
1176
+ </svg>
1177
+ </button>
1178
+ </div>
1179
+ </div>
1180
+
1181
+ <table class="moj-datepicker__calendar moj-js-datepicker-grid" role="grid" aria-labelledby="${titleId}">
1182
+ <thead>
1183
+ <tr></tr>
1184
+ </thead>
1185
+
1186
+ <tbody></tbody>
1187
+ </table>
1188
+
1189
+ <div class="govuk-button-group">
1190
+ <button type="button" class="govuk-button moj-js-datepicker-ok">Select</button>
1191
+ <button type="button" class="govuk-button govuk-button--secondary moj-js-datepicker-cancel">Close</button>
1192
+ </div>`;
1193
+ }
1194
+ createCalendarHeaders() {
1195
+ this.dayLabels.forEach(day => {
1196
+ const html = `<th scope="col"><span aria-hidden="true">${day.substring(0, 3)}</span><span class="govuk-visually-hidden">${day}</span></th>`;
1197
+ const $headerRow = this.$dialog.querySelector('thead > tr');
1198
+ $headerRow.insertAdjacentHTML('beforeend', html);
1199
+ });
1200
+ }
1201
+
1202
+ /**
1203
+ * Pads given number with leading zeros
1204
+ *
1205
+ * @param {number} value - The value to be padded
1206
+ * @param {number} length - The length in characters of the output
1207
+ * @returns {string}
1208
+ */
1209
+ leadingZeros(value, length = 2) {
1210
+ let ret = value.toString();
1211
+ while (ret.length < length) {
1212
+ ret = `0${ret}`;
1213
+ }
1214
+ return ret;
1215
+ }
1216
+ setOptions() {
1217
+ this.setMinAndMaxDatesOnCalendar();
1218
+ this.setExcludedDates();
1219
+ this.setExcludedDays();
1220
+ this.setWeekStartDay();
1221
+ }
1222
+ setMinAndMaxDatesOnCalendar() {
1223
+ if (this.config.minDate) {
1224
+ this.minDate = this.formattedDateFromString(this.config.minDate, null);
1225
+ if (this.minDate && this.currentDate < this.minDate) {
1226
+ this.currentDate = this.minDate;
1227
+ }
1228
+ }
1229
+ if (this.config.maxDate) {
1230
+ this.maxDate = this.formattedDateFromString(this.config.maxDate, null);
1231
+ if (this.maxDate && this.currentDate > this.maxDate) {
1232
+ this.currentDate = this.maxDate;
1233
+ }
1234
+ }
1235
+ }
1236
+ setExcludedDates() {
1237
+ if (this.config.excludedDates) {
1238
+ this.excludedDates = this.config.excludedDates.replace(/\s+/, ' ').split(' ').map(item => {
1239
+ return item.includes('-') ? this.parseDateRangeString(item) : [this.formattedDateFromString(item)];
1240
+ }).reduce((dates, items) => dates.concat(items)).filter(date => date);
1241
+ }
1242
+ }
1243
+
1244
+ /**
1245
+ * Parses a daterange string into an array of dates
1246
+ *
1247
+ * @param {string} datestring - A daterange string in the format "dd/mm/yyyy-dd/mm/yyyy"
1248
+ */
1249
+ parseDateRangeString(datestring) {
1250
+ const dates = [];
1251
+ const [startDate, endDate] = datestring.split('-').map(d => this.formattedDateFromString(d, null));
1252
+ if (startDate && endDate) {
1253
+ const date = new Date(startDate.getTime());
1254
+ /* eslint-disable no-unmodified-loop-condition */
1255
+ while (date <= endDate) {
1256
+ dates.push(new Date(date));
1257
+ date.setDate(date.getDate() + 1);
1258
+ }
1259
+ /* eslint-enable no-unmodified-loop-condition */
1260
+ }
1261
+ return dates;
1262
+ }
1263
+ setExcludedDays() {
1264
+ if (this.config.excludedDays) {
1265
+ // lowercase and arrange dayLabels to put indexOf sunday == 0 for comparison
1266
+ // with getDay() function
1267
+ const weekDays = this.dayLabels.map(item => item.toLowerCase());
1268
+ if (this.config.weekStartDay === 'monday') {
1269
+ weekDays.unshift(weekDays.pop());
1270
+ }
1271
+ this.excludedDays = this.config.excludedDays.replace(/\s+/, ' ').toLowerCase().split(' ').map(item => weekDays.indexOf(item)).filter(item => item !== -1);
1272
+ }
1273
+ }
1274
+ setWeekStartDay() {
1275
+ const weekStartDayParam = this.config.weekStartDay;
1276
+ if (weekStartDayParam && weekStartDayParam.toLowerCase() === 'sunday') {
1277
+ this.config.weekStartDay = 'sunday';
1278
+ // Rotate dayLabels array to put Sunday as the first item
1279
+ this.dayLabels.unshift(this.dayLabels.pop());
1280
+ } else {
1281
+ this.config.weekStartDay = 'monday';
1282
+ }
1283
+ }
1284
+
1285
+ /**
1286
+ * Determine if a date is selectable
1287
+ *
1288
+ * @param {Date} date - the date to check
1289
+ * @returns {boolean}
1290
+ */
1291
+ isExcludedDate(date) {
1292
+ // This comparison does not work correctly - it will exclude the mindate itself
1293
+ // see: https://github.com/ministryofjustice/moj-frontend/issues/923
1294
+ if (this.minDate && this.minDate > date) {
1295
+ return true;
1296
+ }
1297
+
1298
+ // This comparison works as expected - the maxdate will not be excluded
1299
+ if (this.maxDate && this.maxDate < date) {
1300
+ return true;
1301
+ }
1302
+ for (const excludedDate of this.excludedDates) {
1303
+ if (date.toDateString() === excludedDate.toDateString()) {
1304
+ return true;
1305
+ }
1306
+ }
1307
+ if (this.excludedDays.includes(date.getDay())) {
1308
+ return true;
1309
+ }
1310
+ return false;
1311
+ }
1312
+
1313
+ /**
1314
+ * Get a Date object from a string
1315
+ *
1316
+ * @param {string} dateString - string in the format d/m/yyyy dd/mm/yyyy
1317
+ * @param {Date} fallback - date object to return if formatting fails
1318
+ * @returns {Date}
1319
+ */
1320
+ formattedDateFromString(dateString, fallback = new Date()) {
1321
+ let formattedDate = null;
1322
+ // Accepts d/m/yyyy and dd/mm/yyyy
1323
+ const dateFormatPattern = /(\d{1,2})([-/,. ])(\d{1,2})\2(\d{4})/;
1324
+ if (!dateFormatPattern.test(dateString)) return fallback;
1325
+ const match = dateFormatPattern.exec(dateString);
1326
+ const day = match[1];
1327
+ const month = match[3];
1328
+ const year = match[4];
1329
+ formattedDate = new Date(`${year}-${month}-${day}`);
1330
+ if (formattedDate instanceof Date && Number.isFinite(formattedDate.getTime())) {
1331
+ return formattedDate;
1332
+ }
1333
+ return fallback;
1334
+ }
1335
+
1336
+ /**
1337
+ * Get a formatted date string from a Date object
1338
+ *
1339
+ * @param {Date} date - date to format to a string
1340
+ * @returns {string}
1341
+ */
1342
+ formattedDateFromDate(date) {
1343
+ if (this.config.leadingZeros) {
1344
+ return `${this.leadingZeros(date.getDate())}/${this.leadingZeros(date.getMonth() + 1)}/${date.getFullYear()}`;
1345
+ }
1346
+ return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`;
1347
+ }
1348
+
1349
+ /**
1350
+ * Get a human readable date in the format Monday 2 March 2024
1351
+ *
1352
+ * @param {Date} date - Date to format
1353
+ * @returns {string}
1354
+ */
1355
+ formattedDateHuman(date) {
1356
+ return `${this.dayLabels[(date.getDay() + 6) % 7]} ${date.getDate()} ${this.monthLabels[date.getMonth()]} ${date.getFullYear()}`;
1357
+ }
1358
+
1359
+ /**
1360
+ * @param {MouseEvent} event - Click event
1361
+ */
1362
+ backgroundClick(event) {
1363
+ if (this.isOpen() && event.target instanceof Node && !this.$dialog.contains(event.target) && !this.$input.contains(event.target) && !this.$calendarButton.contains(event.target)) {
1364
+ event.preventDefault();
1365
+ this.closeDialog();
1366
+ }
1367
+ }
1368
+
1369
+ /**
1370
+ * @param {KeyboardEvent} event - Keydown event
1371
+ */
1372
+ firstButtonKeydown(event) {
1373
+ if (event.key === 'Tab' && event.shiftKey) {
1374
+ this.$lastButtonInDialog.focus();
1375
+ event.preventDefault();
1376
+ }
1377
+ }
1378
+
1379
+ /**
1380
+ * @param {KeyboardEvent} event - Keydown event
1381
+ */
1382
+ lastButtonKeydown(event) {
1383
+ if (event.key === 'Tab' && !event.shiftKey) {
1384
+ this.$firstButtonInDialog.focus();
1385
+ event.preventDefault();
1386
+ }
1387
+ }
1388
+
1389
+ // render calendar
1390
+ updateCalendar() {
1391
+ this.$dialogTitle.innerHTML = `${this.monthLabels[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`;
1392
+ const day = this.currentDate;
1393
+ const firstOfMonth = new Date(day.getFullYear(), day.getMonth(), 1);
1394
+ let dayOfWeek;
1395
+ if (this.config.weekStartDay === 'monday') {
1396
+ dayOfWeek = firstOfMonth.getDay() === 0 ? 6 : firstOfMonth.getDay() - 1; // Change logic to make Monday first day of week, i.e. 0
1397
+ } else {
1398
+ dayOfWeek = firstOfMonth.getDay();
1399
+ }
1400
+ firstOfMonth.setDate(firstOfMonth.getDate() - dayOfWeek);
1401
+ const thisDay = new Date(firstOfMonth);
1402
+
1403
+ // loop through our days
1404
+ for (const calendarDay of this.calendarDays) {
1405
+ const hidden = thisDay.getMonth() !== day.getMonth();
1406
+ const disabled = this.isExcludedDate(thisDay);
1407
+ calendarDay.update(thisDay, hidden, disabled);
1408
+ thisDay.setDate(thisDay.getDate() + 1);
1409
+ }
1410
+ }
1411
+
1412
+ /**
1413
+ * @param {boolean} [focus] - Focus the day button
1414
+ */
1415
+ setCurrentDate(focus = true) {
1416
+ const {
1417
+ currentDate
1418
+ } = this;
1419
+ this.calendarDays.forEach(calendarDay => {
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);
1424
+ const calendarDayDate = calendarDay.date;
1425
+ calendarDayDate.setHours(0, 0, 0, 0);
1426
+ const today = new Date();
1427
+ today.setHours(0, 0, 0, 0);
1428
+ if (calendarDayDate.getTime() === currentDate.getTime() /* && !calendarDay.button.disabled */) {
1429
+ if (focus) {
1430
+ calendarDay.$button.setAttribute('tabindex', '0');
1431
+ calendarDay.$button.focus();
1432
+ calendarDay.$button.classList.add(this.selectedDayButtonClass);
1433
+ }
1434
+ }
1435
+ if (this.inputDate && calendarDayDate.getTime() === this.inputDate.getTime()) {
1436
+ calendarDay.$button.classList.add(this.currentDayButtonClass);
1437
+ calendarDay.$button.setAttribute('aria-current', 'date');
1438
+ } else {
1439
+ calendarDay.$button.classList.remove(this.currentDayButtonClass);
1440
+ calendarDay.$button.removeAttribute('aria-current');
1441
+ }
1442
+ if (calendarDayDate.getTime() === today.getTime()) {
1443
+ calendarDay.$button.classList.add(this.todayButtonClass);
1444
+ } else {
1445
+ calendarDay.$button.classList.remove(this.todayButtonClass);
1446
+ }
1447
+ });
1448
+
1449
+ // if no date is tab-able, make the first non-disabled date tab-able
1450
+ if (!focus) {
1451
+ const enabledDays = this.calendarDays.filter(calendarDay => {
1452
+ return window.getComputedStyle(calendarDay.$button).display === 'block' && !calendarDay.$button.disabled;
1453
+ });
1454
+ enabledDays[0].$button.setAttribute('tabindex', '0');
1455
+ this.currentDate = enabledDays[0].date;
1456
+ }
1457
+ }
1458
+
1459
+ /**
1460
+ * @param {Date} date - Date to select
1461
+ */
1462
+ selectDate(date) {
1463
+ if (this.isExcludedDate(date)) {
1464
+ return;
1465
+ }
1466
+ this.$calendarButton.querySelector('span').innerText = `Choose date. Selected date is ${this.formattedDateHuman(date)}`;
1467
+ this.$input.value = this.formattedDateFromDate(date);
1468
+ const changeEvent = new Event('change', {
1469
+ bubbles: true,
1470
+ cancelable: true
1471
+ });
1472
+ this.$input.dispatchEvent(changeEvent);
1473
+ this.closeDialog();
1474
+ }
1475
+ isOpen() {
1476
+ return this.$dialog.classList.contains('moj-datepicker__dialog--open');
1477
+ }
1478
+
1479
+ /**
1480
+ * @param {MouseEvent} event - Click event
1481
+ */
1482
+ toggleDialog(event) {
1483
+ event.preventDefault();
1484
+ if (this.isOpen()) {
1485
+ this.closeDialog();
1486
+ } else {
1487
+ this.setMinAndMaxDatesOnCalendar();
1488
+ this.openDialog();
1489
+ }
1490
+ }
1491
+ openDialog() {
1492
+ this.$dialog.hidden = false;
1493
+ this.$dialog.classList.add('moj-datepicker__dialog--open');
1494
+ this.$calendarButton.setAttribute('aria-expanded', 'true');
1495
+
1496
+ // position the dialog
1497
+ // if input is wider than dialog pin it to the right
1498
+ if (this.$input.offsetWidth > this.$dialog.offsetWidth) {
1499
+ this.$dialog.style.right = `0px`;
1500
+ }
1501
+ this.$dialog.style.top = `${this.$input.offsetHeight + 3}px`;
1502
+
1503
+ // get the date from the input element
1504
+ this.inputDate = this.formattedDateFromString(this.$input.value);
1505
+ this.currentDate = this.inputDate;
1506
+ this.currentDate.setHours(0, 0, 0, 0);
1507
+ this.updateCalendar();
1508
+ this.setCurrentDate();
1509
+ }
1510
+ closeDialog() {
1511
+ this.$dialog.hidden = true;
1512
+ this.$dialog.classList.remove('moj-datepicker__dialog--open');
1513
+ this.$calendarButton.setAttribute('aria-expanded', 'false');
1514
+ this.$calendarButton.focus();
1515
+ }
1516
+
1517
+ /**
1518
+ * @param {Date} date - Date to go to
1519
+ * @param {boolean} [focus] - Focus the day button
1520
+ */
1521
+ goToDate(date, focus) {
1522
+ const current = this.currentDate;
1523
+ this.currentDate = date;
1524
+ if (current.getMonth() !== this.currentDate.getMonth() || current.getFullYear() !== this.currentDate.getFullYear()) {
1525
+ this.updateCalendar();
1526
+ }
1527
+ this.setCurrentDate(focus);
1528
+ }
1529
+
1530
+ // day navigation
1531
+ focusNextDay() {
1532
+ const date = new Date(this.currentDate);
1533
+ date.setDate(date.getDate() + 1);
1534
+ this.goToDate(date);
1535
+ }
1536
+ focusPreviousDay() {
1537
+ const date = new Date(this.currentDate);
1538
+ date.setDate(date.getDate() - 1);
1539
+ this.goToDate(date);
1540
+ }
1541
+
1542
+ // week navigation
1543
+ focusNextWeek() {
1544
+ const date = new Date(this.currentDate);
1545
+ date.setDate(date.getDate() + 7);
1546
+ this.goToDate(date);
1547
+ }
1548
+ focusPreviousWeek() {
1549
+ const date = new Date(this.currentDate);
1550
+ date.setDate(date.getDate() - 7);
1551
+ this.goToDate(date);
1552
+ }
1553
+ focusFirstDayOfWeek() {
1554
+ const date = new Date(this.currentDate);
1555
+ const firstDayOfWeekIndex = this.config.weekStartDay === 'sunday' ? 0 : 1;
1556
+ const dayOfWeek = date.getDay();
1557
+ const diff = dayOfWeek >= firstDayOfWeekIndex ? dayOfWeek - firstDayOfWeekIndex : 6 - dayOfWeek;
1558
+ date.setDate(date.getDate() - diff);
1559
+ date.setHours(0, 0, 0, 0);
1560
+ this.goToDate(date);
1561
+ }
1562
+ focusLastDayOfWeek() {
1563
+ const date = new Date(this.currentDate);
1564
+ const lastDayOfWeekIndex = this.config.weekStartDay === 'sunday' ? 6 : 0;
1565
+ const dayOfWeek = date.getDay();
1566
+ const diff = dayOfWeek <= lastDayOfWeekIndex ? lastDayOfWeekIndex - dayOfWeek : 7 - dayOfWeek;
1567
+ date.setDate(date.getDate() + diff);
1568
+ date.setHours(0, 0, 0, 0);
1569
+ this.goToDate(date);
1570
+ }
1571
+
1572
+ /**
1573
+ * Month navigation
1574
+ *
1575
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1576
+ * @param {boolean} [focus] - Focus the day button
1577
+ */
1578
+ focusNextMonth(event, focus = true) {
1579
+ event.preventDefault();
1580
+ const date = new Date(this.currentDate);
1581
+ date.setMonth(date.getMonth() + 1, 1);
1582
+ this.goToDate(date, focus);
1583
+ }
1584
+
1585
+ /**
1586
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1587
+ * @param {boolean} [focus] - Focus the day button
1588
+ */
1589
+ focusPreviousMonth(event, focus = true) {
1590
+ event.preventDefault();
1591
+ const date = new Date(this.currentDate);
1592
+ date.setMonth(date.getMonth() - 1, 1);
1593
+ this.goToDate(date, focus);
1594
+ }
1595
+
1596
+ /**
1597
+ * Year navigation
1598
+ *
1599
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1600
+ * @param {boolean} [focus] - Focus the day button
1601
+ */
1602
+ focusNextYear(event, focus = true) {
1603
+ event.preventDefault();
1604
+ const date = new Date(this.currentDate);
1605
+ date.setFullYear(date.getFullYear() + 1, date.getMonth(), 1);
1606
+ this.goToDate(date, focus);
1607
+ }
1608
+
1609
+ /**
1610
+ * @param {KeyboardEvent | MouseEvent} event - Key press or click event
1611
+ * @param {boolean} [focus] - Focus the day button
1612
+ */
1613
+ focusPreviousYear(event, focus = true) {
1614
+ event.preventDefault();
1615
+ const date = new Date(this.currentDate);
1616
+ date.setFullYear(date.getFullYear() - 1, date.getMonth(), 1);
1617
+ this.goToDate(date, focus);
1618
+ }
1619
+
1620
+ /**
1621
+ * Name for the component used when initialising using data-module attributes.
1622
+ */
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
+ });
1667
+ class DSCalendarDay {
1668
+ /**
1669
+ *
1670
+ * @param {HTMLButtonElement} $button
1671
+ * @param {number} index
1672
+ * @param {number} row
1673
+ * @param {number} column
1674
+ * @param {DatePicker} picker
1675
+ */
1676
+ constructor($button, index, row, column, picker) {
1677
+ this.index = index;
1678
+ this.row = row;
1679
+ this.column = column;
1680
+ this.$button = $button;
1681
+ this.picker = picker;
1682
+ this.date = new Date();
1683
+ this.$button.addEventListener('keydown', this.keyPress.bind(this));
1684
+ this.$button.addEventListener('click', this.click.bind(this));
1685
+ }
1686
+
1687
+ /**
1688
+ * @param {Date} day - the Date for the calendar day
1689
+ * @param {boolean} hidden - visibility of the day
1690
+ * @param {boolean} disabled - is the day selectable or excluded
1691
+ */
1692
+ update(day, hidden, disabled) {
1693
+ const label = day.getDate();
1694
+ let accessibleLabel = this.picker.formattedDateHuman(day);
1695
+ if (disabled) {
1696
+ this.$button.setAttribute('aria-disabled', 'true');
1697
+ accessibleLabel = `Excluded date, ${accessibleLabel}`;
1698
+ } else {
1699
+ this.$button.removeAttribute('aria-disabled');
1700
+ }
1701
+ if (hidden) {
1702
+ this.$button.style.display = 'none';
1703
+ } else {
1704
+ this.$button.style.display = 'block';
1705
+ }
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>`;
1708
+ this.date = new Date(day);
1709
+ }
1710
+
1711
+ /**
1712
+ * @param {MouseEvent} event - Click event
1713
+ */
1714
+ click(event) {
1715
+ this.picker.goToDate(this.date);
1716
+ this.picker.selectDate(this.date);
1717
+ event.stopPropagation();
1718
+ event.preventDefault();
1719
+ }
1720
+
1721
+ /**
1722
+ * @param {KeyboardEvent} event - Keydown event
1723
+ */
1724
+ keyPress(event) {
1725
+ let calendarNavKey = true;
1726
+ switch (event.key) {
1727
+ case 'ArrowLeft':
1728
+ this.picker.focusPreviousDay();
1729
+ break;
1730
+ case 'ArrowRight':
1731
+ this.picker.focusNextDay();
1732
+ break;
1733
+ case 'ArrowUp':
1734
+ this.picker.focusPreviousWeek();
1735
+ break;
1736
+ case 'ArrowDown':
1737
+ this.picker.focusNextWeek();
1738
+ break;
1739
+ case 'Home':
1740
+ this.picker.focusFirstDayOfWeek();
1741
+ break;
1742
+ case 'End':
1743
+ this.picker.focusLastDayOfWeek();
1744
+ break;
1745
+ case 'PageUp':
1746
+ {
1747
+ if (event.shiftKey) {
1748
+ this.picker.focusPreviousYear(event);
1749
+ } else {
1750
+ this.picker.focusPreviousMonth(event);
1751
+ }
1752
+ break;
1753
+ }
1754
+ case 'PageDown':
1755
+ {
1756
+ if (event.shiftKey) {
1757
+ this.picker.focusNextYear(event);
1758
+ } else {
1759
+ this.picker.focusNextMonth(event);
1760
+ }
1761
+ break;
1762
+ }
1763
+ default:
1764
+ calendarNavKey = false;
1765
+ break;
1766
+ }
1767
+ if (calendarNavKey) {
1768
+ event.preventDefault();
1769
+ event.stopPropagation();
1770
+ }
1771
+ }
1772
+ }
1773
+
1774
+ /**
1775
+ * Date picker config
1776
+ *
1777
+ * @typedef {object} DatePickerConfig
1778
+ * @property {string} [excludedDates] - Dates that cannot be selected
1779
+ * @property {string} [excludedDays] - Days that cannot be selected
1780
+ * @property {boolean} [leadingZeros] - Whether to add leading zeroes when populating the field
1781
+ * @property {string} [minDate] - The earliest available date
1782
+ * @property {string} [maxDate] - The latest available date
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
1787
+ */
1788
+
1789
+ /**
1790
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
1791
+ */
1792
+
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;
1811
+ this.createToggleButton();
1812
+ this.setupResponsiveChecks();
1813
+ this.$root.setAttribute('tabindex', '-1');
1814
+ if (this.config.startHidden) {
1815
+ this.hideMenu();
1816
+ }
1817
+ }
1818
+ setupResponsiveChecks() {
1819
+ this.mq = window.matchMedia(this.config.bigModeMediaQuery);
1820
+ this.mq.addListener(this.checkMode.bind(this));
1821
+ this.checkMode();
1822
+ }
1823
+ createToggleButton() {
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) {
1835
+ this.enableBigMode();
1836
+ } else {
1837
+ this.enableSmallMode();
1838
+ }
1839
+ }
1840
+ enableBigMode() {
1841
+ this.showMenu();
1842
+ this.removeCloseButton();
1843
+ }
1844
+ enableSmallMode() {
1845
+ this.hideMenu();
1846
+ this.addCloseButton();
1847
+ }
1848
+ addCloseButton() {
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);
1855
+ }
1856
+ onCloseClick() {
1857
+ this.hideMenu();
1858
+ this.$menuButton.focus();
1859
+ }
1860
+ removeCloseButton() {
1861
+ if (this.$closeButton) {
1862
+ this.$closeButton.remove();
1863
+ this.$closeButton = null;
1864
+ }
1865
+ }
1866
+ hideMenu() {
1867
+ this.$menuButton.setAttribute('aria-expanded', 'false');
1868
+ this.$root.classList.add('moj-js-hidden');
1869
+ this.$menuButton.textContent = this.config.toggleButton.showText;
1870
+ }
1871
+ showMenu() {
1872
+ this.$menuButton.setAttribute('aria-expanded', 'true');
1873
+ this.$root.classList.remove('moj-js-hidden');
1874
+ this.$menuButton.textContent = this.config.toggleButton.hideText;
1875
+ }
1876
+ onMenuButtonClick() {
1877
+ this.toggle();
1878
+ }
1879
+ toggle() {
1880
+ if (this.$menuButton.getAttribute('aria-expanded') === 'false') {
1881
+ this.showMenu();
1882
+ this.$root.focus();
1883
+ } else {
1884
+ this.hideMenu();
1885
+ }
1886
+ }
1887
+
1888
+ /**
1889
+ * Name for the component used when initialising using data-module attributes.
1890
+ */
1891
+ }
1892
+
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 {
1972
+ /**
1973
+ * @param {Element | null} $root - HTML element to use for form validator
1974
+ * @param {FormValidatorConfig} [config] - Form validator config
1975
+ */
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)) {
1980
+ return this;
1981
+ }
1982
+ this.$summary = $summary;
1983
+ this.errors = /** @type {ValidationError[]} */[];
1984
+ this.validators = /** @type {Validator[]} */[];
1985
+ this.originalTitle = document.title;
1986
+ this.$root.addEventListener('submit', this.onSubmit.bind(this));
1987
+ }
1988
+ escapeHtml(string = '') {
1989
+ return String(string).replace(/[&<>"'`=/]/g, name => FormValidator.entityMap[name]);
1990
+ }
1991
+ resetTitle() {
1992
+ document.title = this.originalTitle;
1993
+ }
1994
+ updateTitle() {
1995
+ document.title = `${this.errors.length} errors - ${document.title}`;
1996
+ }
1997
+ showSummary() {
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();
2002
+ }
2003
+ getSummaryHtml() {
2004
+ let html = '<h2 id="error-summary-title" class="govuk-error-summary__title">There is a problem</h2>';
2005
+ html += '<div class="govuk-error-summary__body">';
2006
+ html += '<ul class="govuk-list govuk-error-summary__list">';
2007
+ for (const error of this.errors) {
2008
+ html += '<li>';
2009
+ html += `<a href="#${this.escapeHtml(error.fieldName)}">`;
2010
+ html += this.escapeHtml(error.message);
2011
+ html += '</a>';
2012
+ html += '</li>';
2013
+ }
2014
+ html += '</ul>';
2015
+ html += '</div>';
2016
+ return html;
2017
+ }
2018
+ hideSummary() {
2019
+ this.$summary.classList.add('moj-hidden');
2020
+ this.$summary.removeAttribute('aria-labelledby');
2021
+ }
2022
+
2023
+ /**
2024
+ * @param {SubmitEvent} event - Form submit event
2025
+ */
2026
+ onSubmit(event) {
2027
+ this.removeInlineErrors();
2028
+ this.hideSummary();
2029
+ this.resetTitle();
2030
+ if (!this.validate()) {
2031
+ event.preventDefault();
2032
+ this.updateTitle();
2033
+ this.showSummary();
2034
+ this.showInlineErrors();
2035
+ }
2036
+ }
2037
+ showInlineErrors() {
2038
+ for (const error of this.errors) {
2039
+ this.showInlineError(error);
2040
+ }
2041
+ }
2042
+
2043
+ /**
2044
+ * @param {ValidationError} error
2045
+ */
2046
+ showInlineError(error) {
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);
2065
+ }
2066
+ }
2067
+ removeInlineErrors() {
2068
+ for (const error of this.errors) {
2069
+ this.removeInlineError(error);
2070
+ }
2071
+ }
2072
+
2073
+ /**
2074
+ * @param {ValidationError} error
2075
+ */
2076
+ removeInlineError(error) {
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);
2091
+ }
2092
+ }
2093
+
2094
+ /**
2095
+ * @param {string} fieldName - Field name
2096
+ * @param {ValidationRule[]} rules - Validation rules
2097
+ */
2098
+ addValidator(fieldName, rules) {
2099
+ this.validators.push({
2100
+ fieldName,
2101
+ rules,
2102
+ field: this.$root.elements.namedItem(fieldName)
2103
+ });
2104
+ }
2105
+ validate() {
2106
+ this.errors = [];
2107
+
2108
+ /** @type {Validator | null} */
2109
+ let validator = null;
2110
+
2111
+ /** @type {boolean | string} */
2112
+ let validatorReturnValue = true;
2113
+ let i;
2114
+ let j;
2115
+ for (i = 0; i < this.validators.length; i++) {
2116
+ validator = this.validators[i];
2117
+ for (j = 0; j < validator.rules.length; j++) {
2118
+ validatorReturnValue = validator.rules[j].method(validator.field, validator.rules[j].params);
2119
+ if (typeof validatorReturnValue === 'boolean' && !validatorReturnValue) {
2120
+ this.errors.push({
2121
+ fieldName: validator.fieldName,
2122
+ message: validator.rules[j].message
2123
+ });
2124
+ break;
2125
+ } else if (typeof validatorReturnValue === 'string') {
2126
+ this.errors.push({
2127
+ fieldName: validatorReturnValue,
2128
+ message: validator.rules[j].message
2129
+ });
2130
+ break;
2131
+ }
2132
+ }
2133
+ }
2134
+ return this.errors.length === 0;
2135
+ }
2136
+
2137
+ /**
2138
+ * @type {Record<string, string>}
2139
+ */
2140
+ }
2141
+
2142
+ /**
2143
+ * @typedef {object} FormValidatorConfig
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'
2171
+ */
2172
+ FormValidator.entityMap = {
2173
+ '&': '&amp;',
2174
+ '<': '&lt;',
2175
+ '>': '&gt;',
2176
+ '"': '&quot;',
2177
+ "'": '&#39;',
2178
+ '/': '&#x2F;',
2179
+ '`': '&#x60;',
2180
+ '=': '&#x3D;'
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
+ });
2208
+
2209
+ /* eslint-disable @typescript-eslint/no-empty-function */
2210
+
2211
+
2212
+ /**
2213
+ * @augments {ConfigurableComponent<MultiFileUploadConfig>}
2214
+ */
2215
+ class MultiFileUpload extends ConfigurableComponent {
2216
+ /**
2217
+ * @param {Element | null} $root - HTML element to use for multi file upload
2218
+ * @param {MultiFileUploadConfig} [config] - Multi file upload config
2219
+ */
2220
+ constructor($root, config = {}) {
2221
+ var _this$config$feedback;
2222
+ super($root, config);
2223
+ if (!MultiFileUpload.isSupported()) {
2224
+ return this;
2225
+ }
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;
2231
+ this.setupFileInput();
2232
+ this.setupDropzone();
2233
+ this.setupLabel();
2234
+ this.setupStatusBox();
2235
+ this.$root.addEventListener('click', this.onFileDeleteClick.bind(this));
2236
+ this.$root.classList.add('moj-multi-file-upload--enhanced');
2237
+ }
2238
+ setupDropzone() {
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);
2246
+ }
2247
+ setupLabel() {
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);
2258
+ }
2259
+ setupFileInput() {
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));
2265
+ }
2266
+ setupStatusBox() {
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);
2272
+ }
2273
+
2274
+ /**
2275
+ * @param {DragEvent} event - Drag event
2276
+ */
2277
+ onDragOver(event) {
2278
+ event.preventDefault();
2279
+ this.$dropzone.classList.add('moj-multi-file-upload--dragover');
2280
+ }
2281
+ onDragLeave() {
2282
+ this.$dropzone.classList.remove('moj-multi-file-upload--dragover');
2283
+ }
2284
+
2285
+ /**
2286
+ * @param {DragEvent} event - Drag event
2287
+ */
2288
+ onDrop(event) {
2289
+ event.preventDefault();
2290
+ this.$dropzone.classList.remove('moj-multi-file-upload--dragover');
2291
+ this.$feedbackContainer.classList.remove('moj-hidden');
2292
+ this.$status.textContent = this.config.uploadStatusText;
2293
+ this.uploadFiles(event.dataTransfer.files);
2294
+ }
2295
+
2296
+ /**
2297
+ * @param {FileList} files - File list
2298
+ */
2299
+ uploadFiles(files) {
2300
+ for (const file of Array.from(files)) {
2301
+ this.uploadFile(file);
2302
+ }
2303
+ }
2304
+ onFileChange() {
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)) {
2310
+ return;
2311
+ }
2312
+ $fileInput.value = '';
2313
+ this.$fileInput.replaceWith($fileInput);
2314
+ this.setupFileInput();
2315
+ this.$fileInput.focus();
2316
+ }
2317
+ onFileFocus() {
2318
+ this.$label.classList.add('moj-multi-file-upload--focused');
2319
+ }
2320
+ onFileBlur() {
2321
+ this.$label.classList.remove('moj-multi-file-upload--focused');
2322
+ }
2323
+
2324
+ /**
2325
+ * @param {UploadResponseSuccess['success']} success
2326
+ */
2327
+ getSuccessHtml(success) {
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>`;
2329
+ }
2330
+
2331
+ /**
2332
+ * @param {UploadResponseError['error']} error
2333
+ */
2334
+ getErrorHtml(error) {
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>`;
2336
+ }
2337
+
2338
+ /**
2339
+ * @param {File} file
2340
+ */
2341
+ getFileRow(file) {
2342
+ const $row = document.createElement('div');
2343
+ $row.classList.add('govuk-summary-list__row', 'moj-multi-file-upload__row');
2344
+ $row.innerHTML = `
2345
+ <div class="govuk-summary-list__value moj-multi-file-upload__message">
2346
+ <span class="moj-multi-file-upload__filename">${file.name}</span>
2347
+ <span class="moj-multi-file-upload__progress">0%</span>
2348
+ </div>
2349
+ <div class="govuk-summary-list__actions moj-multi-file-upload__actions"></div>
2350
+ `;
2351
+ return $row;
2352
+ }
2353
+
2354
+ /**
2355
+ * @param {UploadResponseFile} file
2356
+ */
2357
+ getDeleteButton(file) {
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;
2365
+ }
2366
+
2367
+ /**
2368
+ * @param {File} file
2369
+ */
2370
+ uploadFile(file) {
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');
2376
+ const formData = new FormData();
2377
+ formData.append('documents', file);
2378
+ this.$feedbackContainer.querySelector('.moj-multi-file-upload__list').append($item);
2379
+ const xhr = new XMLHttpRequest();
2380
+ const onLoad = () => {
2381
+ if (xhr.status < 200 || xhr.status >= 300 || !('success' in xhr.response)) {
2382
+ return onError();
2383
+ }
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);
2388
+ };
2389
+ const onError = () => {
2390
+ const error = new Error(xhr.response && 'error' in xhr.response ? xhr.response.error.message : xhr.statusText || 'Upload failed');
2391
+ $message.innerHTML = this.getErrorHtml(error);
2392
+ this.$status.textContent = error.message;
2393
+ this.config.hooks.errorHook(this, file, xhr, xhr.responseText, error);
2394
+ };
2395
+ xhr.addEventListener('load', onLoad);
2396
+ xhr.addEventListener('error', onError);
2397
+ xhr.upload.addEventListener('progress', event => {
2398
+ if (!event.lengthComputable) {
2399
+ return;
2400
+ }
2401
+ const percentComplete = Math.round(event.loaded / event.total * 100);
2402
+ $progress.textContent = ` ${percentComplete}%`;
2403
+ });
2404
+ xhr.open('POST', this.config.uploadUrl);
2405
+ xhr.responseType = 'json';
2406
+ xhr.send(formData);
2407
+ }
2408
+
2409
+ /**
2410
+ * @param {MouseEvent} event - Click event
2411
+ */
2412
+ onFileDeleteClick(event) {
2413
+ const $button = event.target;
2414
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-multi-file-upload__delete')) {
2415
+ return;
2416
+ }
2417
+ event.preventDefault(); // if user refreshes page and then deletes
2418
+
2419
+ const xhr = new XMLHttpRequest();
2420
+ xhr.addEventListener('load', () => {
2421
+ if (xhr.status < 200 || xhr.status >= 300) {
2422
+ return;
2423
+ }
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');
2427
+ }
2428
+ const $rowDelete = $rows.find($row => $row.contains($button));
2429
+ if ($rowDelete) $rowDelete.remove();
2430
+ this.config.hooks.deleteHook(this, undefined, xhr, xhr.responseText);
2431
+ });
2432
+ xhr.open('POST', this.config.deleteUrl);
2433
+ xhr.setRequestHeader('Content-Type', 'application/json');
2434
+ xhr.responseType = 'json';
2435
+ xhr.send(JSON.stringify({
2436
+ [$button.name]: $button.value
2437
+ }));
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
+ */
2458
+ }
2459
+
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) {
2610
+ return this;
2611
+ }
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;
2620
+ }
2621
+ setupToggle(idPrefix = '') {
2622
+ const id = `${idPrefix}checkboxes-all`;
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;
2638
+ }
2639
+ onButtonClick() {
2640
+ if (this.checked) {
2641
+ this.uncheckAll();
2642
+ this.$toggleButton.checked = false;
2643
+ } else {
2644
+ this.checkAll();
2645
+ this.$toggleButton.checked = true;
2646
+ }
2647
+ }
2648
+ checkAll() {
2649
+ this.$checkboxes.forEach($input => {
2650
+ $input.checked = true;
2651
+ });
2652
+ this.checked = true;
2653
+ }
2654
+ uncheckAll() {
2655
+ this.$checkboxes.forEach($input => {
2656
+ $input.checked = false;
2657
+ });
2658
+ this.checked = false;
2659
+ }
2660
+
2661
+ /**
2662
+ * @param {MouseEvent} event - Click event
2663
+ */
2664
+ onCheckboxClick(event) {
2665
+ if (!(event.target instanceof HTMLInputElement)) {
2666
+ return;
2667
+ }
2668
+ if (!event.target.checked) {
2669
+ this.$toggleButton.checked = false;
2670
+ this.checked = false;
2671
+ } else {
2672
+ if (this.$checkboxes.filter($input => $input.checked).length === this.$checkboxes.length) {
2673
+ this.$toggleButton.checked = true;
2674
+ this.checked = true;
2675
+ }
2676
+ }
2677
+ }
2678
+
2679
+ /**
2680
+ * Name for the component used when initialising using data-module attributes.
2681
+ */
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'
2725
+ }
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)) {
2737
+ return this;
2738
+ }
2739
+ this.$input = $input;
2740
+ this.$input.setAttribute('spellcheck', 'false');
2741
+ this.createButton();
2742
+ }
2743
+ createButton() {
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);
2754
+ }
2755
+ onButtonClick() {
2756
+ if (this.$input.type === 'password') {
2757
+ this.$input.type = 'text';
2758
+ this.$button.innerHTML = 'Hide <span class="govuk-visually-hidden">password</span>';
2759
+ } else {
2760
+ this.$input.type = 'password';
2761
+ this.$button.innerHTML = 'Show <span class="govuk-visually-hidden">password</span>';
2762
+ }
2763
+ }
2764
+
2765
+ /**
2766
+ * Name for the component used when initialising using data-module attributes.
2767
+ */
2768
+ }
2769
+ PasswordReveal.moduleName = 'moj-password-reveal';
2770
+
2771
+ /**
2772
+ * @augments {ConfigurableComponent<RichTextEditorConfig>}
2773
+ */
2774
+ class RichTextEditor extends ConfigurableComponent {
2775
+ /**
2776
+ * @param {Element | null} $root - HTML element to use for rich text editor
2777
+ * @param {RichTextEditorConfig} config
2778
+ */
2779
+ constructor($root, config = {}) {
2780
+ super($root, config);
2781
+ if (!RichTextEditor.isSupported()) {
2782
+ return this;
2783
+ }
2784
+ const $textarea = this.$root.querySelector('.govuk-textarea');
2785
+ if (!$textarea || !($textarea instanceof HTMLTextAreaElement)) {
2786
+ return this;
2787
+ }
2788
+ this.$textarea = $textarea;
2789
+ this.createToolbar();
2790
+ this.hideDefault();
2791
+ this.configureToolbar();
2792
+ this.keys = {
2793
+ left: 37,
2794
+ right: 39,
2795
+ up: 38,
2796
+ down: 40
2797
+ };
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));
2801
+ }
2802
+
2803
+ /**
2804
+ * @param {KeyboardEvent} event - Click event
2805
+ */
2806
+ onToolbarKeydown(event) {
2807
+ let $focusableButton;
2808
+ switch (event.keyCode) {
2809
+ case this.keys.right:
2810
+ case this.keys.down:
2811
+ {
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
+ }
2820
+ }
2821
+ break;
2822
+ }
2823
+ case this.keys.left:
2824
+ case this.keys.up:
2825
+ {
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
+ }
2834
+ }
2835
+ break;
2836
+ }
2837
+ }
2838
+ }
2839
+ getToolbarHtml() {
2840
+ let html = '';
2841
+ html += '<div class="moj-rich-text-editor__toolbar" role="toolbar">';
2842
+ if (this.config.toolbar.bold) {
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>';
2844
+ }
2845
+ if (this.config.toolbar.italic) {
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>';
2847
+ }
2848
+ if (this.config.toolbar.underline) {
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>';
2850
+ }
2851
+ if (this.config.toolbar.bullets) {
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>';
2853
+ }
2854
+ if (this.config.toolbar.numbers) {
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>';
2856
+ }
2857
+ html += '</div>';
2858
+ return html;
2859
+ }
2860
+ getEnhancedHtml() {
2861
+ return `${this.getToolbarHtml()}<div class="govuk-textarea moj-rich-text-editor__content" contenteditable="true" spellcheck="false"></div>`;
2862
+ }
2863
+ hideDefault() {
2864
+ this.$textarea.classList.add('govuk-visually-hidden');
2865
+ this.$textarea.setAttribute('aria-hidden', 'true');
2866
+ this.$textarea.setAttribute('tabindex', '-1');
2867
+ }
2868
+ createToolbar() {
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;
2876
+ }
2877
+ configureToolbar() {
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));
2883
+ });
2884
+ }
2885
+
2886
+ /**
2887
+ * @param {MouseEvent} event - Click event
2888
+ */
2889
+ onButtonClick(event) {
2890
+ if (!(event.currentTarget instanceof HTMLElement)) {
2891
+ return;
2892
+ }
2893
+ document.execCommand(event.currentTarget.getAttribute('data-command'), false, undefined);
2894
+ }
2895
+ getContent() {
2896
+ return this.$content.innerHTML;
2897
+ }
2898
+ onEditorInput() {
2899
+ this.updateTextarea();
2900
+ }
2901
+ updateTextarea() {
2902
+ document.execCommand('defaultParagraphSeparator', false, 'p');
2903
+ this.$textarea.value = this.getContent();
2904
+ }
2905
+
2906
+ /**
2907
+ * @param {MouseEvent} event - Click event
2908
+ */
2909
+ onLabelClick(event) {
2910
+ event.preventDefault();
2911
+ this.$content.focus();
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
+ */
2920
+ }
2921
+
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)) {
2985
+ return this;
2986
+ }
2987
+ this.$searchContainer = $searchContainer;
2988
+ this.$toggleButtonContainer = $toggleButtonContainer;
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>';
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);
2998
+ document.addEventListener('click', this.onDocumentClick.bind(this));
2999
+ document.addEventListener('focusin', this.onDocumentClick.bind(this));
3000
+ }
3001
+ showMenu() {
3002
+ this.$toggleButton.setAttribute('aria-expanded', 'true');
3003
+ this.$searchContainer.classList.remove('moj-js-hidden');
3004
+ this.$searchContainer.querySelector('input').focus();
3005
+ }
3006
+ hideMenu() {
3007
+ this.$searchContainer.classList.add('moj-js-hidden');
3008
+ this.$toggleButton.setAttribute('aria-expanded', 'false');
3009
+ }
3010
+ onToggleButtonClick() {
3011
+ if (this.$toggleButton.getAttribute('aria-expanded') === 'false') {
3012
+ this.showMenu();
3013
+ } else {
3014
+ this.hideMenu();
3015
+ }
3016
+ }
3017
+
3018
+ /**
3019
+ * @param {MouseEvent | FocusEvent} event
3020
+ */
3021
+ onDocumentClick(event) {
3022
+ if (event.target instanceof Node && !this.$toggleButtonContainer.contains(event.target) && !this.$searchContainer.contains(event.target)) {
3023
+ this.hideMenu();
3024
+ }
3025
+ }
3026
+
3027
+ /**
3028
+ * Name for the component used when initialising using data-module attributes.
3029
+ */
3030
+ }
3031
+
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'
3079
+ }
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) {
3096
+ return this;
3097
+ }
3098
+ this.$head = $head;
3099
+ this.$body = $body;
3100
+ this.$headings = this.$head ? Array.from(this.$head.querySelectorAll('th')) : [];
3101
+ this.createHeadingButtons();
3102
+ this.createStatusBox();
3103
+ this.initialiseSortedColumn();
3104
+ this.$head.addEventListener('click', this.onSortButtonClick.bind(this));
3105
+ }
3106
+ createHeadingButtons() {
3107
+ for (const $heading of this.$headings) {
3108
+ if ($heading.hasAttribute('aria-sort')) {
3109
+ this.createHeadingButton($heading);
3110
+ }
3111
+ }
3112
+ }
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);
3125
+ }
3126
+ createStatusBox() {
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);
3133
+ }
3134
+ initialiseSortedColumn() {
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')) {
3142
+ return;
3143
+ }
3144
+ const $sortedRows = this.sort($rows, columnNumber, sortDirection);
3145
+ this.addRows($sortedRows);
3146
+ }
3147
+
3148
+ /**
3149
+ * @param {MouseEvent} event - Click event
3150
+ */
3151
+ onSortButtonClick(event) {
3152
+ var _$button$getAttribute;
3153
+ const $button = event.target;
3154
+ if (!$button || !($button instanceof HTMLButtonElement) || !$button.parentElement) {
3155
+ return;
3156
+ }
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);
3160
+ const newSortDirection = sortDirection === 'none' || sortDirection === 'descending' ? 'ascending' : 'descending';
3161
+ const $rows = this.getTableRowsArray();
3162
+ const $sortedRows = this.sort($rows, columnNumber, newSortDirection);
3163
+ this.addRows($sortedRows);
3164
+ this.removeButtonStates();
3165
+ this.updateButtonState($button, newSortDirection);
3166
+ }
3167
+
3168
+ /**
3169
+ * @param {HTMLButtonElement} $button
3170
+ * @param {string} direction
3171
+ */
3172
+ updateButtonState($button, direction) {
3173
+ if (!(direction === 'ascending' || direction === 'descending')) {
3174
+ return;
3175
+ }
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;
3181
+ }
3182
+ removeButtonStates() {
3183
+ for (const $heading of this.$headings) {
3184
+ $heading.setAttribute('aria-sort', 'none');
3185
+ }
3186
+ }
3187
+
3188
+ /**
3189
+ * @param {HTMLTableRowElement[]} $rows
3190
+ */
3191
+ addRows($rows) {
3192
+ for (const $row of $rows) {
3193
+ this.$body.append($row);
3194
+ }
3195
+ }
3196
+ getTableRowsArray() {
3197
+ return Array.from(this.$body.querySelectorAll('tr'));
3198
+ }
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)) {
3210
+ return 0;
3211
+ }
3212
+ const valueA = sortDirection === 'ascending' ? this.getCellValue($tdA) : this.getCellValue($tdB);
3213
+ const valueB = sortDirection === 'ascending' ? this.getCellValue($tdB) : this.getCellValue($tdA);
3214
+ return !(typeof valueA === 'number' && typeof valueB === 'number') ? valueA.toString().localeCompare(valueB.toString()) : valueA - valueB;
3215
+ });
3216
+ }
3217
+
3218
+ /**
3219
+ * @param {HTMLElement} $cell
3220
+ */
3221
+ getCellValue($cell) {
3222
+ const val = $cell.getAttribute('data-sort-value') || $cell.innerHTML;
3223
+ const valAsNumber = Number(val);
3224
+ return Number.isFinite(valAsNumber) ? valAsNumber // Exclude invalid numbers, infinity etc
3225
+ : val;
3226
+ }
3227
+
3228
+ /**
3229
+ * Name for the component used when initialising using data-module attributes.
3230
+ */
3231
+ }
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
+ */
3241
+
3242
+ /**
3243
+ * @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
3244
+ */
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'
3271
+ }
3272
+ }
3273
+ });
3274
+
3275
+ /**
3276
+ * @param {Config} [config]
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
+ }
3283
+
3284
+ /**
3285
+ * @typedef {Parameters<typeof GOVUKFrontend.initAll>[0]} Config
3286
+ */
3287
+
3288
+ /**
3289
+ * @import * as GOVUKFrontend from 'govuk-frontend'
3290
+ */
3291
+
3292
+ export { AddAnother, Alert, ButtonMenu, DatePicker, FilterToggleButton, FormValidator, MultiFileUpload, MultiSelect, PasswordReveal, RichTextEditor, SearchToggle, SortableTable, initAll, version };
3293
+ //# sourceMappingURL=all.bundle.mjs.map