govuk_publishing_components 32.1.0 → 33.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/component_guide/accessibility-test.js +0 -1
  3. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-core.js +175 -0
  4. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-ecommerce-tracker.js +4 -4
  5. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-event-tracker.js +5 -13
  6. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-link-tracker.js +80 -309
  7. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-page-views.js +2 -2
  8. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-specialist-link-tracker.js +140 -0
  9. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/init-ga4.js +3 -0
  10. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4.js +1 -0
  11. data/app/assets/javascripts/govuk_publishing_components/components/accordion.js +12 -1
  12. data/app/assets/javascripts/govuk_publishing_components/components/single-page-notification-button.js +24 -8
  13. data/app/assets/javascripts/govuk_publishing_components/components/step-by-step-nav.js +22 -1
  14. data/app/assets/javascripts/govuk_publishing_components/vendor/lux/lux-reporter.js +140 -191
  15. data/app/assets/stylesheets/govuk_publishing_components/components/_big-number.scss +2 -5
  16. data/app/assets/stylesheets/govuk_publishing_components/components/_image-card.scss +1 -5
  17. data/app/assets/stylesheets/govuk_publishing_components/components/_input.scss +3 -5
  18. data/app/assets/stylesheets/govuk_publishing_components/components/_layout-super-navigation-header.scss +10 -30
  19. data/app/assets/stylesheets/govuk_publishing_components/components/_search.scss +0 -7
  20. data/app/views/govuk_publishing_components/components/_accordion.html.erb +14 -1
  21. data/app/views/govuk_publishing_components/components/_error_summary.html.erb +27 -26
  22. data/app/views/govuk_publishing_components/components/_layout_super_navigation_header.html.erb +2 -2
  23. data/app/views/govuk_publishing_components/components/_phase_banner.html.erb +1 -1
  24. data/app/views/govuk_publishing_components/components/_share_links.html.erb +11 -13
  25. data/app/views/govuk_publishing_components/components/_single_page_notification_button.html.erb +1 -1
  26. data/app/views/govuk_publishing_components/components/_step_by_step_nav.html.erb +4 -1
  27. data/app/views/govuk_publishing_components/components/docs/accordion.yml +15 -3
  28. data/app/views/govuk_publishing_components/components/docs/button.yml +10 -0
  29. data/app/views/govuk_publishing_components/components/docs/share_links.yml +59 -30
  30. data/app/views/govuk_publishing_components/components/docs/single_page_notification_button.yml +10 -1
  31. data/app/views/govuk_publishing_components/components/docs/step_by_step_nav.yml +34 -0
  32. data/app/views/govuk_publishing_components/components/feedback/_problem_form.html.erb +1 -1
  33. data/app/views/govuk_publishing_components/components/feedback/_survey_signup_form.html.erb +1 -1
  34. data/app/views/govuk_publishing_components/components/feedback/_yes_no_banner.html.erb +3 -3
  35. data/lib/govuk_publishing_components/presenters/button_helper.rb +9 -2
  36. data/lib/govuk_publishing_components/presenters/single_page_notification_button_helper.rb +25 -1
  37. data/lib/govuk_publishing_components/version.rb +1 -1
  38. data/node_modules/axe-core/axe.js +4559 -4673
  39. data/node_modules/axe-core/axe.min.js +2 -2
  40. data/node_modules/axe-core/package.json +2 -2
  41. data/node_modules/axe-core/sri-history.json +4 -0
  42. data/node_modules/govuk-frontend/README.md +1 -2
  43. data/node_modules/govuk-frontend/govuk/all.js +1398 -273
  44. data/node_modules/govuk-frontend/govuk/common/closest-attribute-value.js +70 -0
  45. data/node_modules/govuk-frontend/govuk/common/index.js +172 -0
  46. data/node_modules/govuk-frontend/govuk/common/normalise-dataset.js +373 -0
  47. data/node_modules/govuk-frontend/govuk/common.js +138 -3
  48. data/node_modules/govuk-frontend/govuk/components/accordion/accordion.js +753 -25
  49. data/node_modules/govuk-frontend/govuk/components/accordion/fixtures.json +54 -22
  50. data/node_modules/govuk-frontend/govuk/components/accordion/macro-options.json +36 -0
  51. data/node_modules/govuk-frontend/govuk/components/accordion/template.njk +7 -1
  52. data/node_modules/govuk-frontend/govuk/components/back-link/fixtures.json +12 -12
  53. data/node_modules/govuk-frontend/govuk/components/breadcrumbs/fixtures.json +22 -22
  54. data/node_modules/govuk-frontend/govuk/components/button/_index.scss +23 -5
  55. data/node_modules/govuk-frontend/govuk/components/button/button.js +365 -107
  56. data/node_modules/govuk-frontend/govuk/components/button/fixtures.json +85 -66
  57. data/node_modules/govuk-frontend/govuk/components/button/template.njk +1 -1
  58. data/node_modules/govuk-frontend/govuk/components/character-count/_index.scss +9 -0
  59. data/node_modules/govuk-frontend/govuk/components/character-count/character-count.js +1033 -121
  60. data/node_modules/govuk-frontend/govuk/components/character-count/fixtures.json +112 -36
  61. data/node_modules/govuk-frontend/govuk/components/character-count/macro-options.json +42 -0
  62. data/node_modules/govuk-frontend/govuk/components/character-count/template.njk +27 -3
  63. data/node_modules/govuk-frontend/govuk/components/checkboxes/checkboxes.js +30 -2
  64. data/node_modules/govuk-frontend/govuk/components/checkboxes/fixtures.json +96 -93
  65. data/node_modules/govuk-frontend/govuk/components/cookie-banner/fixtures.json +46 -46
  66. data/node_modules/govuk-frontend/govuk/components/date-input/fixtures.json +50 -50
  67. data/node_modules/govuk-frontend/govuk/components/details/details.js +43 -13
  68. data/node_modules/govuk-frontend/govuk/components/details/fixtures.json +20 -20
  69. data/node_modules/govuk-frontend/govuk/components/error-message/fixtures.json +20 -20
  70. data/node_modules/govuk-frontend/govuk/components/error-summary/error-summary.js +268 -6
  71. data/node_modules/govuk-frontend/govuk/components/error-summary/fixtures.json +44 -35
  72. data/node_modules/govuk-frontend/govuk/components/error-summary/template.njk +25 -21
  73. data/node_modules/govuk-frontend/govuk/components/fieldset/fixtures.json +51 -39
  74. data/node_modules/govuk-frontend/govuk/components/file-upload/fixtures.json +26 -26
  75. data/node_modules/govuk-frontend/govuk/components/footer/_index.scss +1 -1
  76. data/node_modules/govuk-frontend/govuk/components/footer/fixtures.json +46 -46
  77. data/node_modules/govuk-frontend/govuk/components/footer/macro-options.json +2 -2
  78. data/node_modules/govuk-frontend/govuk/components/header/fixtures.json +93 -38
  79. data/node_modules/govuk-frontend/govuk/components/header/header.js +6 -0
  80. data/node_modules/govuk-frontend/govuk/components/header/macro-options.json +8 -2
  81. data/node_modules/govuk-frontend/govuk/components/header/template.njk +4 -2
  82. data/node_modules/govuk-frontend/govuk/components/hint/fixtures.json +12 -12
  83. data/node_modules/govuk-frontend/govuk/components/input/fixtures.json +80 -80
  84. data/node_modules/govuk-frontend/govuk/components/inset-text/fixtures.json +12 -12
  85. data/node_modules/govuk-frontend/govuk/components/label/fixtures.json +34 -34
  86. data/node_modules/govuk-frontend/govuk/components/notification-banner/fixtures.json +56 -46
  87. data/node_modules/govuk-frontend/govuk/components/notification-banner/notification-banner.js +252 -2
  88. data/node_modules/govuk-frontend/govuk/components/notification-banner/template.njk +1 -1
  89. data/node_modules/govuk-frontend/govuk/components/pagination/_index.scss +10 -7
  90. data/node_modules/govuk-frontend/govuk/components/pagination/fixtures.json +33 -26
  91. data/node_modules/govuk-frontend/govuk/components/panel/fixtures.json +18 -18
  92. data/node_modules/govuk-frontend/govuk/components/phase-banner/fixtures.json +14 -14
  93. data/node_modules/govuk-frontend/govuk/components/radios/fixtures.json +94 -91
  94. data/node_modules/govuk-frontend/govuk/components/radios/radios.js +30 -2
  95. data/node_modules/govuk-frontend/govuk/components/select/fixtures.json +32 -32
  96. data/node_modules/govuk-frontend/govuk/components/skip-link/fixtures.json +22 -20
  97. data/node_modules/govuk-frontend/govuk/components/skip-link/skip-link.js +10 -4
  98. data/node_modules/govuk-frontend/govuk/components/summary-list/fixtures.json +50 -50
  99. data/node_modules/govuk-frontend/govuk/components/table/_index.scss +1 -1
  100. data/node_modules/govuk-frontend/govuk/components/table/fixtures.json +40 -40
  101. data/node_modules/govuk-frontend/govuk/components/tabs/fixtures.json +29 -29
  102. data/node_modules/govuk-frontend/govuk/components/tabs/tabs.js +28 -0
  103. data/node_modules/govuk-frontend/govuk/components/tag/fixtures.json +28 -28
  104. data/node_modules/govuk-frontend/govuk/components/textarea/fixtures.json +34 -34
  105. data/node_modules/govuk-frontend/govuk/components/warning-text/fixtures.json +14 -14
  106. data/node_modules/govuk-frontend/govuk/core/_section-break.scss +1 -1
  107. data/node_modules/govuk-frontend/govuk/helpers/_colour.scss +2 -2
  108. data/node_modules/govuk-frontend/govuk/helpers/_links.scss +6 -6
  109. data/node_modules/govuk-frontend/govuk/i18n.js +390 -0
  110. data/node_modules/govuk-frontend/govuk/macros/i18n.njk +15 -0
  111. data/node_modules/govuk-frontend/govuk/settings/_all.scss +1 -0
  112. data/node_modules/govuk-frontend/govuk/settings/_colours-palette.scss +12 -0
  113. data/node_modules/govuk-frontend/govuk/settings/_compatibility.scss +26 -0
  114. data/node_modules/govuk-frontend/govuk/settings/_typography-font.scss +23 -0
  115. data/node_modules/govuk-frontend/govuk/settings/_typography-responsive.scss +12 -0
  116. data/node_modules/govuk-frontend/govuk/settings/_warnings.scss +53 -0
  117. data/node_modules/govuk-frontend/govuk/tools/_compatibility.scss +20 -6
  118. data/node_modules/govuk-frontend/govuk/vendor/polyfills/Date/now.js +21 -0
  119. data/node_modules/govuk-frontend/govuk/vendor/polyfills/Element/prototype/dataset.js +300 -0
  120. data/node_modules/govuk-frontend/govuk/vendor/polyfills/String/prototype/trim.js +21 -0
  121. data/node_modules/govuk-frontend/govuk-esm/all.mjs +50 -27
  122. data/node_modules/govuk-frontend/govuk-esm/common/closest-attribute-value.mjs +15 -0
  123. data/node_modules/govuk-frontend/govuk-esm/common/index.mjs +159 -0
  124. data/node_modules/govuk-frontend/govuk-esm/common/normalise-dataset.mjs +58 -0
  125. data/node_modules/govuk-frontend/govuk-esm/common.mjs +6 -28
  126. data/node_modules/govuk-frontend/govuk-esm/components/accordion/accordion.mjs +113 -43
  127. data/node_modules/govuk-frontend/govuk-esm/components/button/button.mjs +67 -30
  128. data/node_modules/govuk-frontend/govuk-esm/components/character-count/character-count.mjs +325 -123
  129. data/node_modules/govuk-frontend/govuk-esm/components/checkboxes/checkboxes.mjs +9 -3
  130. data/node_modules/govuk-frontend/govuk-esm/components/details/details.mjs +22 -8
  131. data/node_modules/govuk-frontend/govuk-esm/components/error-summary/error-summary.mjs +48 -6
  132. data/node_modules/govuk-frontend/govuk-esm/components/header/header.mjs +6 -0
  133. data/node_modules/govuk-frontend/govuk-esm/components/notification-banner/notification-banner.mjs +32 -2
  134. data/node_modules/govuk-frontend/govuk-esm/components/radios/radios.mjs +9 -3
  135. data/node_modules/govuk-frontend/govuk-esm/components/skip-link/skip-link.mjs +10 -4
  136. data/node_modules/govuk-frontend/govuk-esm/components/tabs/tabs.mjs +8 -2
  137. data/node_modules/govuk-frontend/govuk-esm/i18n.mjs +380 -0
  138. data/node_modules/govuk-frontend/govuk-esm/vendor/polyfills/Date/now.mjs +13 -0
  139. data/node_modules/govuk-frontend/govuk-esm/vendor/polyfills/Element/prototype/dataset.mjs +68 -0
  140. data/node_modules/govuk-frontend/govuk-esm/vendor/polyfills/String/prototype/trim.mjs +13 -0
  141. data/node_modules/govuk-frontend/govuk-prototype-kit/init.js +7 -0
  142. data/node_modules/govuk-frontend/govuk-prototype-kit/init.scss +12 -0
  143. data/node_modules/govuk-frontend/govuk-prototype-kit.config.json +138 -7
  144. data/node_modules/govuk-frontend/package.json +1 -1
  145. metadata +22 -3
@@ -4,10 +4,24 @@
4
4
  (factory((global.GOVUKFrontend = {})));
5
5
  }(this, (function (exports) { 'use strict';
6
6
 
7
+ /**
8
+ * Common helpers which do not require polyfill.
9
+ *
10
+ * IMPORTANT: If a helper require a polyfill, please isolate it in its own module
11
+ * so that the polyfill can be properly tree-shaken and does not burden
12
+ * the components that do not need that helper
13
+ *
14
+ * @module common/index
15
+ */
16
+
7
17
  /**
8
18
  * TODO: Ideally this would be a NodeList.prototype.forEach polyfill
9
19
  * This seems to fail in IE8, requires more investigation.
10
20
  * See: https://github.com/imagitama/nodelist-foreach-polyfill
21
+ *
22
+ * @param {NodeListOf<Element>} nodes - NodeList from querySelectorAll()
23
+ * @param {nodeListIterator} callback - Callback function to run for each node
24
+ * @returns {undefined}
11
25
  */
12
26
  function nodeListForEach (nodes, callback) {
13
27
  if (window.NodeList.prototype.forEach) {
@@ -18,9 +32,13 @@ function nodeListForEach (nodes, callback) {
18
32
  }
19
33
  }
20
34
 
21
- // Used to generate a unique string, allows multiple instances of the component without
22
- // Them conflicting with each other.
23
- // https://stackoverflow.com/a/8809472
35
+ /**
36
+ * Used to generate a unique string, allows multiple instances of the component
37
+ * without them conflicting with each other.
38
+ * https://stackoverflow.com/a/8809472
39
+ *
40
+ * @returns {string} Unique ID
41
+ */
24
42
  function generateUniqueID () {
25
43
  var d = new Date().getTime();
26
44
  if (typeof window.performance !== 'undefined' && typeof window.performance.now === 'function') {
@@ -33,6 +51,500 @@ function generateUniqueID () {
33
51
  })
34
52
  }
35
53
 
54
+ /**
55
+ * Config flattening function
56
+ *
57
+ * Takes any number of objects, flattens them into namespaced key-value pairs,
58
+ * (e.g. {'i18n.showSection': 'Show section'}) and combines them together, with
59
+ * greatest priority on the LAST item passed in.
60
+ *
61
+ * @returns {object} A flattened object of key-value pairs.
62
+ */
63
+ function mergeConfigs (/* configObject1, configObject2, ...configObjects */) {
64
+ /**
65
+ * Function to take nested objects and flatten them to a dot-separated keyed
66
+ * object. Doing this means we don't need to do any deep/recursive merging of
67
+ * each of our objects, nor transform our dataset from a flat list into a
68
+ * nested object.
69
+ *
70
+ * @param {object} configObject - Deeply nested object
71
+ * @returns {object} Flattened object with dot-separated keys
72
+ */
73
+ var flattenObject = function (configObject) {
74
+ // Prepare an empty return object
75
+ var flattenedObject = {};
76
+
77
+ // Our flattening function, this is called recursively for each level of
78
+ // depth in the object. At each level we prepend the previous level names to
79
+ // the key using `prefix`.
80
+ var flattenLoop = function (obj, prefix) {
81
+ // Loop through keys...
82
+ for (var key in obj) {
83
+ // Check to see if this is a prototypical key/value,
84
+ // if it is, skip it.
85
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) {
86
+ continue
87
+ }
88
+ var value = obj[key];
89
+ var prefixedKey = prefix ? prefix + '.' + key : key;
90
+ if (typeof value === 'object') {
91
+ // If the value is a nested object, recurse over that too
92
+ flattenLoop(value, prefixedKey);
93
+ } else {
94
+ // Otherwise, add this value to our return object
95
+ flattenedObject[prefixedKey] = value;
96
+ }
97
+ }
98
+ };
99
+
100
+ // Kick off the recursive loop
101
+ flattenLoop(configObject);
102
+ return flattenedObject
103
+ };
104
+
105
+ // Start with an empty object as our base
106
+ var formattedConfigObject = {};
107
+
108
+ // Loop through each of the remaining passed objects and push their keys
109
+ // one-by-one into configObject. Any duplicate keys will override the existing
110
+ // key with the new value.
111
+ for (var i = 0; i < arguments.length; i++) {
112
+ var obj = flattenObject(arguments[i]);
113
+ for (var key in obj) {
114
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
115
+ formattedConfigObject[key] = obj[key];
116
+ }
117
+ }
118
+ }
119
+
120
+ return formattedConfigObject
121
+ }
122
+
123
+ /**
124
+ * Extracts keys starting with a particular namespace from a flattened config
125
+ * object, removing the namespace in the process.
126
+ *
127
+ * @param {object} configObject - The object to extract key-value pairs from.
128
+ * @param {string} namespace - The namespace to filter keys with.
129
+ * @returns {object} Flattened object with dot-separated key namespace removed
130
+ */
131
+ function extractConfigByNamespace (configObject, namespace) {
132
+ // Check we have what we need
133
+ if (!configObject || typeof configObject !== 'object') {
134
+ throw new Error('Provide a `configObject` of type "object".')
135
+ }
136
+ if (!namespace || typeof namespace !== 'string') {
137
+ throw new Error('Provide a `namespace` of type "string" to filter the `configObject` by.')
138
+ }
139
+ var newObject = {};
140
+ for (var key in configObject) {
141
+ // Split the key into parts, using . as our namespace separator
142
+ var keyParts = key.split('.');
143
+ // Check if the first namespace matches the configured namespace
144
+ if (Object.prototype.hasOwnProperty.call(configObject, key) && keyParts[0] === namespace) {
145
+ // Remove the first item (the namespace) from the parts array,
146
+ // but only if there is more than one part (we don't want blank keys!)
147
+ if (keyParts.length > 1) {
148
+ keyParts.shift();
149
+ }
150
+ // Join the remaining parts back together
151
+ var newKey = keyParts.join('.');
152
+ // Add them to our new object
153
+ newObject[newKey] = configObject[key];
154
+ }
155
+ }
156
+ return newObject
157
+ }
158
+
159
+ /**
160
+ * @callback nodeListIterator
161
+ * @param {Element} value - The current node being iterated on
162
+ * @param {number} index - The current index in the iteration
163
+ * @param {NodeListOf<Element>} nodes - NodeList from querySelectorAll()
164
+ * @returns {undefined}
165
+ */
166
+
167
+ /**
168
+ * Internal support for selecting messages to render, with placeholder
169
+ * interpolation and locale-aware number formatting and pluralisation
170
+ *
171
+ * @class
172
+ * @private
173
+ * @param {TranslationsFlattened} translations - Key-value pairs of the translation strings to use.
174
+ * @param {object} [config] - Configuration options for the function.
175
+ * @param {string} config.locale - An overriding locale for the PluralRules functionality.
176
+ */
177
+ function I18n (translations, config) {
178
+ // Make list of translations available throughout function
179
+ this.translations = translations || {};
180
+
181
+ // The locale to use for PluralRules and NumberFormat
182
+ this.locale = (config && config.locale) || document.documentElement.lang || 'en';
183
+ }
184
+
185
+ /**
186
+ * The most used function - takes the key for a given piece of UI text and
187
+ * returns the appropriate string.
188
+ *
189
+ * @param {string} lookupKey - The lookup key of the string to use.
190
+ * @param {object} options - Any options passed with the translation string, e.g: for string interpolation.
191
+ * @returns {string} The appropriate translation string.
192
+ */
193
+ I18n.prototype.t = function (lookupKey, options) {
194
+ if (!lookupKey) {
195
+ // Print a console error if no lookup key has been provided
196
+ throw new Error('i18n: lookup key missing')
197
+ }
198
+
199
+ // If the `count` option is set, determine which plural suffix is needed and
200
+ // change the lookupKey to match. We check to see if it's undefined instead of
201
+ // falsy, as this could legitimately be 0.
202
+ if (options && typeof options.count !== 'undefined') {
203
+ // Get the plural suffix
204
+ lookupKey = lookupKey + '.' + this.getPluralSuffix(lookupKey, options.count);
205
+ }
206
+
207
+ if (lookupKey in this.translations) {
208
+ // Fetch the translation string for that lookup key
209
+ var translationString = this.translations[lookupKey];
210
+
211
+ // Check for ${} placeholders in the translation string
212
+ if (translationString.match(/%{(.\S+)}/)) {
213
+ if (!options) {
214
+ throw new Error('i18n: cannot replace placeholders in string if no option data provided')
215
+ }
216
+
217
+ return this.replacePlaceholders(translationString, options)
218
+ } else {
219
+ return translationString
220
+ }
221
+ } else {
222
+ // If the key wasn't found in our translations object,
223
+ // return the lookup key itself as the fallback
224
+ return lookupKey
225
+ }
226
+ };
227
+
228
+ /**
229
+ * Takes a translation string with placeholders, and replaces the placeholders
230
+ * with the provided data
231
+ *
232
+ * @param {string} translationString - The translation string
233
+ * @param {object} options - Any options passed with the translation string, e.g: for string interpolation.
234
+ * @returns {string} The translation string to output, with ${} placeholders replaced
235
+ */
236
+ I18n.prototype.replacePlaceholders = function (translationString, options) {
237
+ var formatter;
238
+
239
+ if (this.hasIntlNumberFormatSupport()) {
240
+ formatter = new Intl.NumberFormat(this.locale);
241
+ }
242
+
243
+ return translationString.replace(/%{(.\S+)}/g, function (placeholderWithBraces, placeholderKey) {
244
+ if (Object.prototype.hasOwnProperty.call(options, placeholderKey)) {
245
+ var placeholderValue = options[placeholderKey];
246
+
247
+ // If a user has passed `false` as the value for the placeholder
248
+ // treat it as though the value should not be displayed
249
+ if (placeholderValue === false) {
250
+ return ''
251
+ }
252
+
253
+ // If the placeholder's value is a number, localise the number formatting
254
+ if (typeof placeholderValue === 'number' && formatter) {
255
+ return formatter.format(placeholderValue)
256
+ }
257
+
258
+ return placeholderValue
259
+ } else {
260
+ throw new Error('i18n: no data found to replace ' + placeholderWithBraces + ' placeholder in string')
261
+ }
262
+ })
263
+ };
264
+
265
+ /**
266
+ * Check to see if the browser supports Intl and Intl.PluralRules.
267
+ *
268
+ * It requires all conditions to be met in order to be supported:
269
+ * - The browser supports the Intl class (true in IE11)
270
+ * - The implementation of Intl supports PluralRules (NOT true in IE11)
271
+ * - The browser/OS has plural rules for the current locale (browser dependent)
272
+ *
273
+ * @returns {boolean} Returns true if all conditions are met. Returns false otherwise.
274
+ */
275
+ I18n.prototype.hasIntlPluralRulesSupport = function () {
276
+ return Boolean(window.Intl && ('PluralRules' in window.Intl && Intl.PluralRules.supportedLocalesOf(this.locale).length))
277
+ };
278
+
279
+ /**
280
+ * Check to see if the browser supports Intl and Intl.NumberFormat.
281
+ *
282
+ * It requires all conditions to be met in order to be supported:
283
+ * - The browser supports the Intl class (true in IE11)
284
+ * - The implementation of Intl supports NumberFormat (also true in IE11)
285
+ * - The browser/OS has number formatting rules for the current locale (browser dependent)
286
+ *
287
+ * @returns {boolean} Returns true if all conditions are met. Returns false otherwise.
288
+ */
289
+ I18n.prototype.hasIntlNumberFormatSupport = function () {
290
+ return Boolean(window.Intl && ('NumberFormat' in window.Intl && Intl.NumberFormat.supportedLocalesOf(this.locale).length))
291
+ };
292
+
293
+ /**
294
+ * Get the appropriate suffix for the plural form.
295
+ *
296
+ * Uses Intl.PluralRules (or our own fallback implementation) to get the
297
+ * 'preferred' form to use for the given count.
298
+ *
299
+ * Checks that a translation has been provided for that plural form – if it
300
+ * hasn't, it'll fall back to the 'other' plural form (unless that doesn't exist
301
+ * either, in which case an error will be thrown)
302
+ *
303
+ * @param {string} lookupKey - The lookup key of the string to use.
304
+ * @param {number} count - Number used to determine which pluralisation to use.
305
+ * @returns {PluralRule} The suffix associated with the correct pluralisation for this locale.
306
+ */
307
+ I18n.prototype.getPluralSuffix = function (lookupKey, count) {
308
+ // Validate that the number is actually a number.
309
+ //
310
+ // Number(count) will turn anything that can't be converted to a Number type
311
+ // into 'NaN'. isFinite filters out NaN, as it isn't a finite number.
312
+ count = Number(count);
313
+ if (!isFinite(count)) { return 'other' }
314
+
315
+ var preferredForm;
316
+
317
+ // Check to verify that all the requirements for Intl.PluralRules are met.
318
+ // If so, we can use that instead of our custom implementation. Otherwise,
319
+ // use the hardcoded fallback.
320
+ if (this.hasIntlPluralRulesSupport()) {
321
+ preferredForm = new Intl.PluralRules(this.locale).select(count);
322
+ } else {
323
+ preferredForm = this.selectPluralFormUsingFallbackRules(count);
324
+ }
325
+
326
+ // Use the correct plural form if provided
327
+ if (lookupKey + '.' + preferredForm in this.translations) {
328
+ return preferredForm
329
+ // Fall back to `other` if the plural form is missing, but log a warning
330
+ // to the console
331
+ } else if (lookupKey + '.other' in this.translations) {
332
+ if (console && 'warn' in console) {
333
+ console.warn('i18n: Missing plural form ".' + preferredForm + '" for "' +
334
+ this.locale + '" locale. Falling back to ".other".');
335
+ }
336
+
337
+ return 'other'
338
+ // If the required `other` plural form is missing, all we can do is error
339
+ } else {
340
+ throw new Error(
341
+ 'i18n: Plural form ".other" is required for "' + this.locale + '" locale'
342
+ )
343
+ }
344
+ };
345
+
346
+ /**
347
+ * Get the plural form using our fallback implementation
348
+ *
349
+ * This is split out into a separate function to make it easier to test the
350
+ * fallback behaviour in an environment where Intl.PluralRules exists.
351
+ *
352
+ * @param {number} count - Number used to determine which pluralisation to use.
353
+ * @returns {PluralRule} The pluralisation form for count in this locale.
354
+ */
355
+ I18n.prototype.selectPluralFormUsingFallbackRules = function (count) {
356
+ // Currently our custom code can only handle positive integers, so let's
357
+ // make sure our number is one of those.
358
+ count = Math.abs(Math.floor(count));
359
+
360
+ var ruleset = this.getPluralRulesForLocale();
361
+
362
+ if (ruleset) {
363
+ return I18n.pluralRules[ruleset](count)
364
+ }
365
+
366
+ return 'other'
367
+ };
368
+
369
+ /**
370
+ * Work out which pluralisation rules to use for the current locale
371
+ *
372
+ * The locale may include a regional indicator (such as en-GB), but we don't
373
+ * usually care about this part, as pluralisation rules are usually the same
374
+ * regardless of region. There are exceptions, however, (e.g. Portuguese) so
375
+ * this searches by both the full and shortened locale codes, just to be sure.
376
+ *
377
+ * @returns {PluralRuleName | undefined} The name of the pluralisation rule to use (a key for one
378
+ * of the functions in this.pluralRules)
379
+ */
380
+ I18n.prototype.getPluralRulesForLocale = function () {
381
+ var locale = this.locale;
382
+ var localeShort = locale.split('-')[0];
383
+
384
+ // Look through the plural rules map to find which `pluralRule` is
385
+ // appropriate for our current `locale`.
386
+ for (var pluralRule in I18n.pluralRulesMap) {
387
+ if (Object.prototype.hasOwnProperty.call(I18n.pluralRulesMap, pluralRule)) {
388
+ var languages = I18n.pluralRulesMap[pluralRule];
389
+ for (var i = 0; i < languages.length; i++) {
390
+ if (languages[i] === locale || languages[i] === localeShort) {
391
+ return pluralRule
392
+ }
393
+ }
394
+ }
395
+ }
396
+ };
397
+
398
+ /**
399
+ * Map of plural rules to languages where those rules apply.
400
+ *
401
+ * Note: These groups are named for the most dominant or recognisable language
402
+ * that uses each system. The groupings do not imply that the languages are
403
+ * related to one another. Many languages have evolved the same systems
404
+ * independently of one another.
405
+ *
406
+ * Code to support more languages can be found in the i18n spike:
407
+ * {@link https://github.com/alphagov/govuk-frontend/blob/spike-i18n-support/src/govuk/i18n.mjs}
408
+ *
409
+ * Languages currently supported:
410
+ *
411
+ * Arabic: Arabic (ar)
412
+ * Chinese: Burmese (my), Chinese (zh), Indonesian (id), Japanese (ja),
413
+ * Javanese (jv), Korean (ko), Malay (ms), Thai (th), Vietnamese (vi)
414
+ * French: Armenian (hy), Bangla (bn), French (fr), Gujarati (gu), Hindi (hi),
415
+ * Persian Farsi (fa), Punjabi (pa), Zulu (zu)
416
+ * German: Afrikaans (af), Albanian (sq), Azerbaijani (az), Basque (eu),
417
+ * Bulgarian (bg), Catalan (ca), Danish (da), Dutch (nl), English (en),
418
+ * Estonian (et), Finnish (fi), Georgian (ka), German (de), Greek (el),
419
+ * Hungarian (hu), Luxembourgish (lb), Norwegian (no), Somali (so),
420
+ * Swahili (sw), Swedish (sv), Tamil (ta), Telugu (te), Turkish (tr),
421
+ * Urdu (ur)
422
+ * Irish: Irish Gaelic (ga)
423
+ * Russian: Russian (ru), Ukrainian (uk)
424
+ * Scottish: Scottish Gaelic (gd)
425
+ * Spanish: European Portuguese (pt-PT), Italian (it), Spanish (es)
426
+ * Welsh: Welsh (cy)
427
+ *
428
+ * @type {Object<PluralRuleName, string[]>}
429
+ */
430
+ I18n.pluralRulesMap = {
431
+ arabic: ['ar'],
432
+ chinese: ['my', 'zh', 'id', 'ja', 'jv', 'ko', 'ms', 'th', 'vi'],
433
+ french: ['hy', 'bn', 'fr', 'gu', 'hi', 'fa', 'pa', 'zu'],
434
+ german: [
435
+ 'af', 'sq', 'az', 'eu', 'bg', 'ca', 'da', 'nl', 'en', 'et', 'fi', 'ka',
436
+ 'de', 'el', 'hu', 'lb', 'no', 'so', 'sw', 'sv', 'ta', 'te', 'tr', 'ur'
437
+ ],
438
+ irish: ['ga'],
439
+ russian: ['ru', 'uk'],
440
+ scottish: ['gd'],
441
+ spanish: ['pt-PT', 'it', 'es'],
442
+ welsh: ['cy']
443
+ };
444
+
445
+ /**
446
+ * Different pluralisation rule sets
447
+ *
448
+ * Returns the appropriate suffix for the plural form associated with `n`.
449
+ * Possible suffixes: 'zero', 'one', 'two', 'few', 'many', 'other' (the actual
450
+ * meaning of each differs per locale). 'other' should always exist, even in
451
+ * languages without plurals, such as Chinese.
452
+ * {@link https://cldr.unicode.org/index/cldr-spec/plural-rules}
453
+ *
454
+ * The count must be a positive integer. Negative numbers and decimals aren't accounted for
455
+ *
456
+ * @type {Object<string, function(number): PluralRule>}
457
+ */
458
+ I18n.pluralRules = {
459
+ arabic: function (n) {
460
+ if (n === 0) { return 'zero' }
461
+ if (n === 1) { return 'one' }
462
+ if (n === 2) { return 'two' }
463
+ if (n % 100 >= 3 && n % 100 <= 10) { return 'few' }
464
+ if (n % 100 >= 11 && n % 100 <= 99) { return 'many' }
465
+ return 'other'
466
+ },
467
+ chinese: function () {
468
+ return 'other'
469
+ },
470
+ french: function (n) {
471
+ return n === 0 || n === 1 ? 'one' : 'other'
472
+ },
473
+ german: function (n) {
474
+ return n === 1 ? 'one' : 'other'
475
+ },
476
+ irish: function (n) {
477
+ if (n === 1) { return 'one' }
478
+ if (n === 2) { return 'two' }
479
+ if (n >= 3 && n <= 6) { return 'few' }
480
+ if (n >= 7 && n <= 10) { return 'many' }
481
+ return 'other'
482
+ },
483
+ russian: function (n) {
484
+ var lastTwo = n % 100;
485
+ var last = lastTwo % 10;
486
+ if (last === 1 && lastTwo !== 11) { return 'one' }
487
+ if (last >= 2 && last <= 4 && !(lastTwo >= 12 && lastTwo <= 14)) { return 'few' }
488
+ if (last === 0 || (last >= 5 && last <= 9) || (lastTwo >= 11 && lastTwo <= 14)) { return 'many' }
489
+ // Note: The 'other' suffix is only used by decimal numbers in Russian.
490
+ // We don't anticipate it being used, but it's here for consistency.
491
+ return 'other'
492
+ },
493
+ scottish: function (n) {
494
+ if (n === 1 || n === 11) { return 'one' }
495
+ if (n === 2 || n === 12) { return 'two' }
496
+ if ((n >= 3 && n <= 10) || (n >= 13 && n <= 19)) { return 'few' }
497
+ return 'other'
498
+ },
499
+ spanish: function (n) {
500
+ if (n === 1) { return 'one' }
501
+ if (n % 1000000 === 0 && n !== 0) { return 'many' }
502
+ return 'other'
503
+ },
504
+ welsh: function (n) {
505
+ if (n === 0) { return 'zero' }
506
+ if (n === 1) { return 'one' }
507
+ if (n === 2) { return 'two' }
508
+ if (n === 3) { return 'few' }
509
+ if (n === 6) { return 'many' }
510
+ return 'other'
511
+ }
512
+ };
513
+
514
+ /**
515
+ * Supported languages for plural rules
516
+ *
517
+ * @typedef {'arabic' | 'chinese' | 'french' | 'german' | 'irish' | 'russian' | 'scottish' | 'spanish' | 'welsh'} PluralRuleName
518
+ */
519
+
520
+ /**
521
+ * Plural rule category mnemonic tags
522
+ *
523
+ * @typedef {'zero' | 'one' | 'two' | 'few' | 'many' | 'other'} PluralRule
524
+ */
525
+
526
+ /**
527
+ * Translated message by plural rule they correspond to.
528
+ *
529
+ * Allows to group pluralised messages under a single key when passing
530
+ * translations to a component's constructor
531
+ *
532
+ * @typedef {object} TranslationPluralForms
533
+ * @property {string} [other] - General plural form
534
+ * @property {string} [zero] - Plural form used with 0
535
+ * @property {string} [one] - Plural form used with 1
536
+ * @property {string} [two] - Plural form used with 2
537
+ * @property {string} [few] - Plural form used for a few
538
+ * @property {string} [many] - Plural form used for many
539
+ */
540
+
541
+ /**
542
+ * Translated messages (flattened)
543
+ *
544
+ * @private
545
+ * @typedef {Object<string, string> | {}} TranslationsFlattened
546
+ */
547
+
36
548
  (function(undefined) {
37
549
 
38
550
  // Detection from https://github.com/Financial-Times/polyfill-service/blob/master/packages/polyfill-library/polyfills/Object/defineProperty/detect.js
@@ -773,13 +1285,188 @@ if (detect) return
773
1285
 
774
1286
  }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
775
1287
 
776
- function Accordion ($module) {
1288
+ (function(undefined) {
1289
+
1290
+ // Detection from https://github.com/mdn/content/blob/cf607d68522cd35ee7670782d3ee3a361eaef2e4/files/en-us/web/javascript/reference/global_objects/string/trim/index.md#polyfill
1291
+ var detect = ('trim' in String.prototype);
1292
+
1293
+ if (detect) return
1294
+
1295
+ // Polyfill from https://github.com/mdn/content/blob/cf607d68522cd35ee7670782d3ee3a361eaef2e4/files/en-us/web/javascript/reference/global_objects/string/trim/index.md#polyfill
1296
+ String.prototype.trim = function () {
1297
+ return this.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, '');
1298
+ };
1299
+
1300
+ }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
1301
+
1302
+ (function(undefined) {
1303
+
1304
+ // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-library/13cf7c340974d128d557580b5e2dafcd1b1192d1/polyfills/Element/prototype/dataset/detect.js
1305
+ var detect = (function(){
1306
+ if (!document.documentElement.dataset) {
1307
+ return false;
1308
+ }
1309
+ var el = document.createElement('div');
1310
+ el.setAttribute("data-a-b", "c");
1311
+ return el.dataset && el.dataset.aB == "c";
1312
+ }());
1313
+
1314
+ if (detect) return
1315
+
1316
+ // Polyfill derived from https://raw.githubusercontent.com/Financial-Times/polyfill-library/13cf7c340974d128d557580b5e2dafcd1b1192d1/polyfills/Element/prototype/dataset/polyfill.js
1317
+ Object.defineProperty(Element.prototype, 'dataset', {
1318
+ get: function() {
1319
+ var element = this;
1320
+ var attributes = this.attributes;
1321
+ var map = {};
1322
+
1323
+ for (var i = 0; i < attributes.length; i++) {
1324
+ var attribute = attributes[i];
1325
+
1326
+ // This regex has been edited from the original polyfill, to add
1327
+ // support for period (.) separators in data-* attribute names. These
1328
+ // are allowed in the HTML spec, but were not covered by the original
1329
+ // polyfill's regex. We use periods in our i18n implementation.
1330
+ if (attribute && attribute.name && (/^data-\w[.\w-]*$/).test(attribute.name)) {
1331
+ var name = attribute.name;
1332
+ var value = attribute.value;
1333
+
1334
+ var propName = name.substr(5).replace(/-./g, function (prop) {
1335
+ return prop.charAt(1).toUpperCase();
1336
+ });
1337
+
1338
+ // If this browser supports __defineGetter__ and __defineSetter__,
1339
+ // continue using defineProperty. If not (like IE 8 and below), we use
1340
+ // a hacky fallback which at least gives an object in the right format
1341
+ if ('__defineGetter__' in Object.prototype && '__defineSetter__' in Object.prototype) {
1342
+ Object.defineProperty(map, propName, {
1343
+ enumerable: true,
1344
+ get: function() {
1345
+ return this.value;
1346
+ }.bind({value: value || ''}),
1347
+ set: function setter(name, value) {
1348
+ if (typeof value !== 'undefined') {
1349
+ this.setAttribute(name, value);
1350
+ } else {
1351
+ this.removeAttribute(name);
1352
+ }
1353
+ }.bind(element, name)
1354
+ });
1355
+ } else {
1356
+ map[propName] = value;
1357
+ }
1358
+
1359
+ }
1360
+ }
1361
+
1362
+ return map;
1363
+ }
1364
+ });
1365
+
1366
+ }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
1367
+
1368
+ /**
1369
+ * Normalise string
1370
+ *
1371
+ * 'If it looks like a duck, and it quacks like a duck…' 🦆
1372
+ *
1373
+ * If the passed value looks like a boolean or a number, convert it to a boolean
1374
+ * or number.
1375
+ *
1376
+ * Designed to be used to convert config passed via data attributes (which are
1377
+ * always strings) into something sensible.
1378
+ *
1379
+ * @param {string} value - The value to normalise
1380
+ * @returns {string | boolean | number | undefined} Normalised data
1381
+ */
1382
+ function normaliseString (value) {
1383
+ if (typeof value !== 'string') {
1384
+ return value
1385
+ }
1386
+
1387
+ var trimmedValue = value.trim();
1388
+
1389
+ if (trimmedValue === 'true') {
1390
+ return true
1391
+ }
1392
+
1393
+ if (trimmedValue === 'false') {
1394
+ return false
1395
+ }
1396
+
1397
+ // Empty / whitespace-only strings are considered finite so we need to check
1398
+ // the length of the trimmed string as well
1399
+ if (trimmedValue.length > 0 && isFinite(trimmedValue)) {
1400
+ return Number(trimmedValue)
1401
+ }
1402
+
1403
+ return value
1404
+ }
1405
+
1406
+ /**
1407
+ * Normalise dataset
1408
+ *
1409
+ * Loop over an object and normalise each value using normaliseData function
1410
+ *
1411
+ * @param {DOMStringMap} dataset - HTML element dataset
1412
+ * @returns {Object<string, string | boolean | number | undefined>} Normalised dataset
1413
+ */
1414
+ function normaliseDataset (dataset) {
1415
+ var out = {};
1416
+
1417
+ for (var key in dataset) {
1418
+ out[key] = normaliseString(dataset[key]);
1419
+ }
1420
+
1421
+ return out
1422
+ }
1423
+
1424
+ /**
1425
+ * @constant
1426
+ * @type {AccordionTranslations}
1427
+ * @see Default value for {@link AccordionConfig.i18n}
1428
+ * @default
1429
+ */
1430
+ var ACCORDION_TRANSLATIONS = {
1431
+ hideAllSections: 'Hide all sections',
1432
+ hideSection: 'Hide',
1433
+ hideSectionAriaLabel: 'Hide this section',
1434
+ showAllSections: 'Show all sections',
1435
+ showSection: 'Show',
1436
+ showSectionAriaLabel: 'Show this section'
1437
+ };
1438
+
1439
+ /**
1440
+ * Accordion component
1441
+ *
1442
+ * This allows a collection of sections to be collapsed by default, showing only
1443
+ * their headers. Sections can be expanded or collapsed individually by clicking
1444
+ * their headers. A "Show all sections" button is also added to the top of the
1445
+ * accordion, which switches to "Hide all sections" when all the sections are
1446
+ * expanded.
1447
+ *
1448
+ * The state of each section is saved to the DOM via the `aria-expanded`
1449
+ * attribute, which also provides accessibility.
1450
+ *
1451
+ * @class
1452
+ * @param {HTMLElement} $module - HTML element to use for accordion
1453
+ * @param {AccordionConfig} [config] - Accordion config
1454
+ */
1455
+ function Accordion ($module, config) {
777
1456
  this.$module = $module;
778
- this.moduleId = $module.getAttribute('id');
779
1457
  this.$sections = $module.querySelectorAll('.govuk-accordion__section');
780
- this.$showAllButton = '';
781
1458
  this.browserSupportsSessionStorage = helper.checkForSessionStorage();
782
1459
 
1460
+ var defaultConfig = {
1461
+ i18n: ACCORDION_TRANSLATIONS
1462
+ };
1463
+ this.config = mergeConfigs(
1464
+ defaultConfig,
1465
+ config || {},
1466
+ normaliseDataset($module.dataset)
1467
+ );
1468
+ this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'));
1469
+
783
1470
  this.controlsClass = 'govuk-accordion__controls';
784
1471
  this.showAllClass = 'govuk-accordion__show-all';
785
1472
  this.showAllTextClass = 'govuk-accordion__show-all-text';
@@ -870,7 +1557,7 @@ Accordion.prototype.constructHeaderMarkup = function ($headerWrapper, index) {
870
1557
  // Create a button element that will replace the '.govuk-accordion__section-button' span
871
1558
  var $button = document.createElement('button');
872
1559
  $button.setAttribute('type', 'button');
873
- $button.setAttribute('aria-controls', this.moduleId + '-content-' + (index + 1));
1560
+ $button.setAttribute('aria-controls', this.$module.id + '-content-' + (index + 1));
874
1561
 
875
1562
  // Copy all attributes (https://developer.mozilla.org/en-US/docs/Web/API/Element/attributes) from $span to $button
876
1563
  for (var i = 0; i < $span.attributes.length; i++) {
@@ -987,17 +1674,34 @@ Accordion.prototype.setExpanded = function (expanded, $section) {
987
1674
  var $icon = $section.querySelector('.' + this.upChevronIconClass);
988
1675
  var $showHideText = $section.querySelector('.' + this.sectionShowHideTextClass);
989
1676
  var $button = $section.querySelector('.' + this.sectionButtonClass);
990
- var newButtonText = expanded ? 'Hide' : 'Show';
991
-
992
- // Build additional copy of "this section" for assistive technology and place inside toggle link
993
- var $visuallyHiddenText = document.createElement('span');
994
- $visuallyHiddenText.classList.add('govuk-visually-hidden');
995
- $visuallyHiddenText.innerHTML = ' this section';
1677
+ var newButtonText = expanded
1678
+ ? this.i18n.t('hideSection')
1679
+ : this.i18n.t('showSection');
996
1680
 
997
- $showHideText.innerHTML = newButtonText;
998
- $showHideText.appendChild($visuallyHiddenText);
1681
+ $showHideText.innerText = newButtonText;
999
1682
  $button.setAttribute('aria-expanded', expanded);
1000
1683
 
1684
+ // Update aria-label combining
1685
+ var $header = $section.querySelector('.' + this.sectionHeadingTextClass);
1686
+ var ariaLabelParts = [$header.innerText.trim()];
1687
+
1688
+ var $summary = $section.querySelector('.' + this.sectionSummaryClass);
1689
+ if ($summary) {
1690
+ ariaLabelParts.push($summary.innerText.trim());
1691
+ }
1692
+
1693
+ var ariaLabelMessage = expanded
1694
+ ? this.i18n.t('hideSectionAriaLabel')
1695
+ : this.i18n.t('showSectionAriaLabel');
1696
+ ariaLabelParts.push(ariaLabelMessage);
1697
+
1698
+ /*
1699
+ * Join with a comma to add pause for assistive technology.
1700
+ * Example: [heading]Section A ,[pause] Show this section.
1701
+ * https://accessibility.blog.gov.uk/2017/12/18/what-working-on-gov-uk-navigation-taught-us-about-accessibility/
1702
+ */
1703
+ $button.setAttribute('aria-label', ariaLabelParts.join(' , '));
1704
+
1001
1705
  // Swap icon, change class
1002
1706
  if (expanded) {
1003
1707
  $section.classList.add(this.sectionExpandedClass);
@@ -1032,9 +1736,11 @@ Accordion.prototype.checkIfAllSectionsOpen = function () {
1032
1736
  Accordion.prototype.updateShowAllButton = function (expanded) {
1033
1737
  var $showAllIcon = this.$showAllButton.querySelector('.' + this.upChevronIconClass);
1034
1738
  var $showAllText = this.$showAllButton.querySelector('.' + this.showAllTextClass);
1035
- var newButtonText = expanded ? 'Hide all sections' : 'Show all sections';
1739
+ var newButtonText = expanded
1740
+ ? this.i18n.t('hideAllSections')
1741
+ : this.i18n.t('showAllSections');
1036
1742
  this.$showAllButton.setAttribute('aria-expanded', expanded);
1037
- $showAllText.innerHTML = newButtonText;
1743
+ $showAllText.innerText = newButtonText;
1038
1744
 
1039
1745
  // Swap icon, toggle class
1040
1746
  if (expanded) {
@@ -1097,17 +1803,14 @@ Accordion.prototype.setInitialState = function ($section) {
1097
1803
  };
1098
1804
 
1099
1805
  /**
1100
- * Create an element to improve semantics of the section button with punctuation
1101
- * @return {object} DOM element
1102
- *
1103
- * Used to add pause (with a comma) for assistive technology.
1104
- * Example: [heading]Section A ,[pause] Show this section.
1105
- * https://accessibility.blog.gov.uk/2017/12/18/what-working-on-gov-uk-navigation-taught-us-about-accessibility/
1106
- *
1107
- * Adding punctuation to the button can also improve its general semantics by dividing its contents
1108
- * into thematic chunks.
1109
- * See https://github.com/alphagov/govuk-frontend/issues/2327#issuecomment-922957442
1110
- */
1806
+ * Create an element to improve semantics of the section button with punctuation
1807
+ *
1808
+ * @returns {HTMLSpanElement} DOM element
1809
+ *
1810
+ * Adding punctuation to the button can also improve its general semantics by dividing its contents
1811
+ * into thematic chunks.
1812
+ * See https://github.com/alphagov/govuk-frontend/issues/2327#issuecomment-922957442
1813
+ */
1111
1814
  Accordion.prototype.getButtonPunctuationEl = function () {
1112
1815
  var $punctuationEl = document.createElement('span');
1113
1816
  $punctuationEl.classList.add('govuk-visually-hidden', 'govuk-accordion__section-heading-divider');
@@ -1115,6 +1818,35 @@ Accordion.prototype.getButtonPunctuationEl = function () {
1115
1818
  return $punctuationEl
1116
1819
  };
1117
1820
 
1821
+ /**
1822
+ * Accordion config
1823
+ *
1824
+ * @typedef {object} AccordionConfig
1825
+ * @property {AccordionTranslations} [i18n = ACCORDION_TRANSLATIONS] - See constant {@link ACCORDION_TRANSLATIONS}
1826
+ */
1827
+
1828
+ /**
1829
+ * Accordion translations
1830
+ *
1831
+ * @typedef {object} AccordionTranslations
1832
+ *
1833
+ * Messages used by the component for the labels of its buttons. This includes
1834
+ * the visible text shown on screen, and text to help assistive technology users
1835
+ * for the buttons toggling each section.
1836
+ * @property {string} [hideAllSections] - The text content for the 'Hide all
1837
+ * sections' button, used when at least one section is expanded.
1838
+ * @property {string} [hideSection] - The text content for the 'Hide'
1839
+ * button, used when a section is expanded.
1840
+ * @property {string} [hideSectionAriaLabel] - The text content appended to the
1841
+ * 'Hide' button's accessible name when a section is expanded.
1842
+ * @property {string} [showAllSections] - The text content for the 'Show all
1843
+ * sections' button, used when all sections are collapsed.
1844
+ * @property {string} [showSection] - The text content for the 'Show'
1845
+ * button, used when a section is collapsed.
1846
+ * @property {string} [showSectionAriaLabel] - The text content appended to the
1847
+ * 'Show' button's accessible name when a section is expanded.
1848
+ */
1849
+
1118
1850
  (function(undefined) {
1119
1851
 
1120
1852
  // Detection from https://github.com/Financial-Times/polyfill-service/blob/master/packages/polyfill-library/polyfills/Window/detect.js
@@ -1388,44 +2120,79 @@ if (detect) return
1388
2120
  var KEY_SPACE = 32;
1389
2121
  var DEBOUNCE_TIMEOUT_IN_SECONDS = 1;
1390
2122
 
1391
- function Button ($module) {
2123
+ /**
2124
+ * JavaScript enhancements for the Button component
2125
+ *
2126
+ * @class
2127
+ * @param {HTMLElement} $module - The element this component controls
2128
+ * @param {ButtonConfig} config - Button config
2129
+ */
2130
+ function Button ($module, config) {
2131
+ if (!$module) {
2132
+ return this
2133
+ }
2134
+
1392
2135
  this.$module = $module;
1393
2136
  this.debounceFormSubmitTimer = null;
2137
+
2138
+ var defaultConfig = {
2139
+ preventDoubleClick: false
2140
+ };
2141
+ this.config = mergeConfigs(
2142
+ defaultConfig,
2143
+ config || {},
2144
+ normaliseDataset($module.dataset)
2145
+ );
1394
2146
  }
1395
2147
 
1396
2148
  /**
1397
- * JavaScript 'shim' to trigger the click event of element(s) when the space key is pressed.
1398
- *
1399
- * Created since some Assistive Technologies (for example some Screenreaders)
1400
- * will tell a user to press space on a 'button', so this functionality needs to be shimmed
1401
- * See https://github.com/alphagov/govuk_elements/pull/272#issuecomment-233028270
1402
- *
1403
- * @param {object} event event
1404
- */
1405
- Button.prototype.handleKeyDown = function (event) {
1406
- // get the target element
1407
- var target = event.target;
1408
- // if the element has a role='button' and the pressed key is a space, we'll simulate a click
1409
- if (target.getAttribute('role') === 'button' && event.keyCode === KEY_SPACE) {
1410
- event.preventDefault();
1411
- // trigger the target's click event
1412
- target.click();
2149
+ * Initialise component
2150
+ */
2151
+ Button.prototype.init = function () {
2152
+ if (!this.$module) {
2153
+ return
1413
2154
  }
1414
- };
2155
+
2156
+ this.$module.addEventListener('keydown', this.handleKeyDown);
2157
+ this.$module.addEventListener('click', this.debounce.bind(this));
2158
+ };
1415
2159
 
1416
2160
  /**
1417
- * If the click quickly succeeds a previous click then nothing will happen.
1418
- * This stops people accidentally causing multiple form submissions by
1419
- * double clicking buttons.
1420
- */
1421
- Button.prototype.debounce = function (event) {
2161
+ * Trigger a click event when the space key is pressed
2162
+ *
2163
+ * Some screen readers tell users they can activate things with the 'button'
2164
+ * role, so we need to match the functionality of native HTML buttons
2165
+ *
2166
+ * See https://github.com/alphagov/govuk_elements/pull/272#issuecomment-233028270
2167
+ *
2168
+ * @param {KeyboardEvent} event
2169
+ */
2170
+ Button.prototype.handleKeyDown = function (event) {
1422
2171
  var target = event.target;
1423
- // Check the button that is clicked on has the preventDoubleClick feature enabled
1424
- if (target.getAttribute('data-prevent-double-click') !== 'true') {
2172
+
2173
+ if (target.getAttribute('role') === 'button' && event.keyCode === KEY_SPACE) {
2174
+ event.preventDefault(); // prevent the page from scrolling
2175
+ target.click();
2176
+ }
2177
+ };
2178
+
2179
+ /**
2180
+ * Debounce double-clicks
2181
+ *
2182
+ * If the click quickly succeeds a previous click then nothing will happen. This
2183
+ * stops people accidentally causing multiple form submissions by double
2184
+ * clicking buttons.
2185
+ *
2186
+ * @param {MouseEvent} event
2187
+ * @returns {undefined | false} - Returns undefined, or false when debounced
2188
+ */
2189
+ Button.prototype.debounce = function (event) {
2190
+ // Check the button that was clicked has preventDoubleClick enabled
2191
+ if (!this.config.preventDoubleClick) {
1425
2192
  return
1426
2193
  }
1427
2194
 
1428
- // If the timer is still running then we want to prevent the click from submitting the form
2195
+ // If the timer is still running, prevent the click from submitting the form
1429
2196
  if (this.debounceFormSubmitTimer) {
1430
2197
  event.preventDefault();
1431
2198
  return false
@@ -1437,13 +2204,13 @@ Button.prototype.debounce = function (event) {
1437
2204
  };
1438
2205
 
1439
2206
  /**
1440
- * Initialise an event listener for keydown at document level
1441
- * this will help listening for later inserted elements with a role="button"
1442
- */
1443
- Button.prototype.init = function () {
1444
- this.$module.addEventListener('keydown', this.handleKeyDown);
1445
- this.$module.addEventListener('click', this.debounce);
1446
- };
2207
+ * Button config
2208
+ *
2209
+ * @typedef {object} ButtonConfig
2210
+ * @property {boolean} [preventDoubleClick = false] -
2211
+ * Prevent accidental double clicks on submit buttons from submitting forms
2212
+ * multiple times.
2213
+ */
1447
2214
 
1448
2215
  /**
1449
2216
  * JavaScript 'polyfill' for HTML5's <details> and <summary> elements
@@ -1455,6 +2222,12 @@ Button.prototype.init = function () {
1455
2222
  var KEY_ENTER = 13;
1456
2223
  var KEY_SPACE$1 = 32;
1457
2224
 
2225
+ /**
2226
+ * Details component
2227
+ *
2228
+ * @class
2229
+ * @param {HTMLElement} $module - HTML element to use for details
2230
+ */
1458
2231
  function Details ($module) {
1459
2232
  this.$module = $module;
1460
2233
  }
@@ -1521,9 +2294,10 @@ Details.prototype.polyfillDetails = function () {
1521
2294
  };
1522
2295
 
1523
2296
  /**
1524
- * Define a statechange function that updates aria-expanded and style.display
1525
- * @param {object} summary element
1526
- */
2297
+ * Define a statechange function that updates aria-expanded and style.display
2298
+ *
2299
+ * @returns {boolean} Returns true
2300
+ */
1527
2301
  Details.prototype.polyfillSetAttributes = function () {
1528
2302
  if (this.$module.hasAttribute('open')) {
1529
2303
  this.$module.removeAttribute('open');
@@ -1539,10 +2313,11 @@ Details.prototype.polyfillSetAttributes = function () {
1539
2313
  };
1540
2314
 
1541
2315
  /**
1542
- * Handle cross-modal click events
1543
- * @param {object} node element
1544
- * @param {function} callback function
1545
- */
2316
+ * Handle cross-modal click events
2317
+ *
2318
+ * @param {object} node - element
2319
+ * @param {polyfillHandleInputsCallback} callback - function
2320
+ */
1546
2321
  Details.prototype.polyfillHandleInputs = function (node, callback) {
1547
2322
  node.addEventListener('keypress', function (event) {
1548
2323
  var target = event.target;
@@ -1576,7 +2351,181 @@ Details.prototype.polyfillHandleInputs = function (node, callback) {
1576
2351
  node.addEventListener('click', callback);
1577
2352
  };
1578
2353
 
1579
- function CharacterCount ($module) {
2354
+ /**
2355
+ * @callback polyfillHandleInputsCallback
2356
+ * @param {KeyboardEvent} event - Keyboard event
2357
+ * @returns {undefined}
2358
+ */
2359
+
2360
+ (function(undefined) {
2361
+
2362
+ // Detection from https://github.com/Financial-Times/polyfill-library/blob/v3.111.0/polyfills/Date/now/detect.js
2363
+ var detect = ('Date' in self && 'now' in self.Date && 'getTime' in self.Date.prototype);
2364
+
2365
+ if (detect) return
2366
+
2367
+ // Polyfill from https://polyfill.io/v3/polyfill.js?version=3.111.0&features=Date.now&flags=always
2368
+ Date.now = function () {
2369
+ return new Date().getTime();
2370
+ };
2371
+
2372
+ }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
2373
+
2374
+ (function(undefined) {
2375
+
2376
+ // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/detect.js
2377
+ var detect = (
2378
+ 'document' in this && "matches" in document.documentElement
2379
+ );
2380
+
2381
+ if (detect) return
2382
+
2383
+ // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/polyfill.js
2384
+ Element.prototype.matches = Element.prototype.webkitMatchesSelector || Element.prototype.oMatchesSelector || Element.prototype.msMatchesSelector || Element.prototype.mozMatchesSelector || function matches(selector) {
2385
+ var element = this;
2386
+ var elements = (element.document || element.ownerDocument).querySelectorAll(selector);
2387
+ var index = 0;
2388
+
2389
+ while (elements[index] && elements[index] !== element) {
2390
+ ++index;
2391
+ }
2392
+
2393
+ return !!elements[index];
2394
+ };
2395
+
2396
+ }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
2397
+
2398
+ (function(undefined) {
2399
+
2400
+ // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/detect.js
2401
+ var detect = (
2402
+ 'document' in this && "closest" in document.documentElement
2403
+ );
2404
+
2405
+ if (detect) return
2406
+
2407
+ // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/polyfill.js
2408
+ Element.prototype.closest = function closest(selector) {
2409
+ var node = this;
2410
+
2411
+ while (node) {
2412
+ if (node.matches(selector)) return node;
2413
+ else node = 'SVGElement' in window && node instanceof SVGElement ? node.parentNode : node.parentElement;
2414
+ }
2415
+
2416
+ return null;
2417
+ };
2418
+
2419
+ }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
2420
+
2421
+ /**
2422
+ * Returns the value of the given attribute closest to the given element (including itself)
2423
+ *
2424
+ * @param {HTMLElement} $element - The element to start walking the DOM tree up
2425
+ * @param {string} attributeName - The name of the attribute
2426
+ * @returns {string | undefined} Attribute value
2427
+ */
2428
+ function closestAttributeValue ($element, attributeName) {
2429
+ var closestElementWithAttribute = $element.closest('[' + attributeName + ']');
2430
+ if (closestElementWithAttribute) {
2431
+ return closestElementWithAttribute.getAttribute(attributeName)
2432
+ }
2433
+ }
2434
+
2435
+ /**
2436
+ * @constant
2437
+ * @type {CharacterCountTranslations}
2438
+ * @see Default value for {@link CharacterCountConfig.i18n}
2439
+ * @default
2440
+ */
2441
+ var CHARACTER_COUNT_TRANSLATIONS = {
2442
+ // Characters
2443
+ charactersUnderLimit: {
2444
+ one: 'You have %{count} character remaining',
2445
+ other: 'You have %{count} characters remaining'
2446
+ },
2447
+ charactersAtLimit: 'You have 0 characters remaining',
2448
+ charactersOverLimit: {
2449
+ one: 'You have %{count} character too many',
2450
+ other: 'You have %{count} characters too many'
2451
+ },
2452
+ // Words
2453
+ wordsUnderLimit: {
2454
+ one: 'You have %{count} word remaining',
2455
+ other: 'You have %{count} words remaining'
2456
+ },
2457
+ wordsAtLimit: 'You have 0 words remaining',
2458
+ wordsOverLimit: {
2459
+ one: 'You have %{count} word too many',
2460
+ other: 'You have %{count} words too many'
2461
+ },
2462
+ textareaDescription: {
2463
+ other: ''
2464
+ }
2465
+ };
2466
+
2467
+ /**
2468
+ * JavaScript enhancements for the CharacterCount component
2469
+ *
2470
+ * Tracks the number of characters or words in the `.govuk-js-character-count`
2471
+ * `<textarea>` inside the element. Displays a message with the remaining number
2472
+ * of characters/words available, or the number of characters/words in excess.
2473
+ *
2474
+ * You can configure the message to only appear after a certain percentage
2475
+ * of the available characters/words has been entered.
2476
+ *
2477
+ * @class
2478
+ * @param {HTMLElement} $module - The element this component controls
2479
+ * @param {CharacterCountConfig} [config] - Character count config
2480
+ */
2481
+ function CharacterCount ($module, config) {
2482
+ if (!$module) {
2483
+ return this
2484
+ }
2485
+
2486
+ var defaultConfig = {
2487
+ threshold: 0,
2488
+ i18n: CHARACTER_COUNT_TRANSLATIONS
2489
+ };
2490
+
2491
+ // Read config set using dataset ('data-' values)
2492
+ var datasetConfig = normaliseDataset($module.dataset);
2493
+
2494
+ // To ensure data-attributes take complete precedence, even if they change the
2495
+ // type of count, we need to reset the `maxlength` and `maxwords` from the
2496
+ // JavaScript config.
2497
+ //
2498
+ // We can't mutate `config`, though, as it may be shared across multiple
2499
+ // components inside `initAll`.
2500
+ var configOverrides = {};
2501
+ if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
2502
+ configOverrides = {
2503
+ maxlength: false,
2504
+ maxwords: false
2505
+ };
2506
+ }
2507
+
2508
+ this.config = mergeConfigs(
2509
+ defaultConfig,
2510
+ config || {},
2511
+ configOverrides,
2512
+ datasetConfig
2513
+ );
2514
+
2515
+ this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'), {
2516
+ // Read the fallback if necessary rather than have it set in the defaults
2517
+ locale: closestAttributeValue($module, 'lang')
2518
+ });
2519
+
2520
+ // Determine the limit attribute (characters or words)
2521
+ if (this.config.maxwords) {
2522
+ this.maxLength = this.config.maxwords;
2523
+ } else if (this.config.maxlength) {
2524
+ this.maxLength = this.config.maxlength;
2525
+ } else {
2526
+ return
2527
+ }
2528
+
1580
2529
  this.$module = $module;
1581
2530
  this.$textarea = $module.querySelector('.govuk-js-character-count');
1582
2531
  this.$visibleCountMessage = null;
@@ -1584,26 +2533,28 @@ function CharacterCount ($module) {
1584
2533
  this.lastInputTimestamp = null;
1585
2534
  }
1586
2535
 
1587
- CharacterCount.prototype.defaults = {
1588
- characterCountAttribute: 'data-maxlength',
1589
- wordCountAttribute: 'data-maxwords'
1590
- };
1591
-
1592
- // Initialize component
2536
+ /**
2537
+ * Initialise component
2538
+ */
1593
2539
  CharacterCount.prototype.init = function () {
1594
2540
  // Check that required elements are present
1595
2541
  if (!this.$textarea) {
1596
2542
  return
1597
2543
  }
1598
2544
 
1599
- // Check for module
1600
- var $module = this.$module;
1601
2545
  var $textarea = this.$textarea;
1602
- var $fallbackLimitMessage = document.getElementById($textarea.id + '-info');
2546
+ var $textareaDescription = document.getElementById($textarea.id + '-info');
1603
2547
 
1604
- // Move the fallback count message to be immediately after the textarea
2548
+ // Inject a decription for the textarea if none is present already
2549
+ // for when the component was rendered with no maxlength, maxwords
2550
+ // nor custom textareaDescriptionText
2551
+ if ($textareaDescription.innerText.match(/^\s*$/)) {
2552
+ $textareaDescription.innerText = this.i18n.t('textareaDescription', { count: this.maxLength });
2553
+ }
2554
+
2555
+ // Move the textarea description to be immediately after the textarea
1605
2556
  // Kept for backwards compatibility
1606
- $textarea.insertAdjacentElement('afterend', $fallbackLimitMessage);
2557
+ $textarea.insertAdjacentElement('afterend', $textareaDescription);
1607
2558
 
1608
2559
  // Create the *screen reader* specific live-updating counter
1609
2560
  // This doesn't need any styling classes, as it is never visible
@@ -1611,36 +2562,20 @@ CharacterCount.prototype.init = function () {
1611
2562
  $screenReaderCountMessage.className = 'govuk-character-count__sr-status govuk-visually-hidden';
1612
2563
  $screenReaderCountMessage.setAttribute('aria-live', 'polite');
1613
2564
  this.$screenReaderCountMessage = $screenReaderCountMessage;
1614
- $fallbackLimitMessage.insertAdjacentElement('afterend', $screenReaderCountMessage);
2565
+ $textareaDescription.insertAdjacentElement('afterend', $screenReaderCountMessage);
1615
2566
 
1616
2567
  // Create our live-updating counter element, copying the classes from the
1617
- // fallback element for backwards compatibility as these may have been configured
2568
+ // textarea description for backwards compatibility as these may have been
2569
+ // configured
1618
2570
  var $visibleCountMessage = document.createElement('div');
1619
- $visibleCountMessage.className = $fallbackLimitMessage.className;
2571
+ $visibleCountMessage.className = $textareaDescription.className;
1620
2572
  $visibleCountMessage.classList.add('govuk-character-count__status');
1621
2573
  $visibleCountMessage.setAttribute('aria-hidden', 'true');
1622
2574
  this.$visibleCountMessage = $visibleCountMessage;
1623
- $fallbackLimitMessage.insertAdjacentElement('afterend', $visibleCountMessage);
1624
-
1625
- // Hide the fallback limit message
1626
- $fallbackLimitMessage.classList.add('govuk-visually-hidden');
2575
+ $textareaDescription.insertAdjacentElement('afterend', $visibleCountMessage);
1627
2576
 
1628
- // Read options set using dataset ('data-' values)
1629
- this.options = this.getDataset($module);
1630
-
1631
- // Determine the limit attribute (characters or words)
1632
- var countAttribute = this.defaults.characterCountAttribute;
1633
- if (this.options.maxwords) {
1634
- countAttribute = this.defaults.wordCountAttribute;
1635
- }
1636
-
1637
- // Save the element limit
1638
- this.maxLength = $module.getAttribute(countAttribute);
1639
-
1640
- // Check for limit
1641
- if (!this.maxLength) {
1642
- return
1643
- }
2577
+ // Hide the textarea description
2578
+ $textareaDescription.classList.add('govuk-visually-hidden');
1644
2579
 
1645
2580
  // Remove hard limit if set
1646
2581
  $textarea.removeAttribute('maxlength');
@@ -1648,9 +2583,9 @@ CharacterCount.prototype.init = function () {
1648
2583
  this.bindChangeEvents();
1649
2584
 
1650
2585
  // When the page is restored after navigating 'back' in some browsers the
1651
- // state of the character count is not restored until *after* the DOMContentLoaded
1652
- // event is fired, so we need to manually update it after the pageshow event
1653
- // in browsers that support it.
2586
+ // state of the character count is not restored until *after* the
2587
+ // DOMContentLoaded event is fired, so we need to manually update it after the
2588
+ // pageshow event in browsers that support it.
1654
2589
  if ('onpageshow' in window) {
1655
2590
  window.addEventListener('pageshow', this.updateCountMessage.bind(this));
1656
2591
  } else {
@@ -1659,35 +2594,12 @@ CharacterCount.prototype.init = function () {
1659
2594
  this.updateCountMessage();
1660
2595
  };
1661
2596
 
1662
- // Read data attributes
1663
- CharacterCount.prototype.getDataset = function (element) {
1664
- var dataset = {};
1665
- var attributes = element.attributes;
1666
- if (attributes) {
1667
- for (var i = 0; i < attributes.length; i++) {
1668
- var attribute = attributes[i];
1669
- var match = attribute.name.match(/^data-(.+)/);
1670
- if (match) {
1671
- dataset[match[1]] = attribute.value;
1672
- }
1673
- }
1674
- }
1675
- return dataset
1676
- };
1677
-
1678
- // Counts characters or words in text
1679
- CharacterCount.prototype.count = function (text) {
1680
- var length;
1681
- if (this.options.maxwords) {
1682
- var tokens = text.match(/\S+/g) || []; // Matches consecutive non-whitespace chars
1683
- length = tokens.length;
1684
- } else {
1685
- length = text.length;
1686
- }
1687
- return length
1688
- };
1689
-
1690
- // Bind input propertychange to the elements and update based on the change
2597
+ /**
2598
+ * Bind change events
2599
+ *
2600
+ * Set up event listeners on the $textarea so that the count messages update
2601
+ * when the user types.
2602
+ */
1691
2603
  CharacterCount.prototype.bindChangeEvents = function () {
1692
2604
  var $textarea = this.$textarea;
1693
2605
  $textarea.addEventListener('keyup', this.handleKeyUp.bind(this));
@@ -1697,10 +2609,52 @@ CharacterCount.prototype.bindChangeEvents = function () {
1697
2609
  $textarea.addEventListener('blur', this.handleBlur.bind(this));
1698
2610
  };
1699
2611
 
1700
- // Speech recognition software such as Dragon NaturallySpeaking will modify the
1701
- // fields by directly changing its `value`. These changes don't trigger events
1702
- // in JavaScript, so we need to poll to handle when and if they occur.
1703
- CharacterCount.prototype.checkIfValueChanged = function () {
2612
+ /**
2613
+ * Handle key up event
2614
+ *
2615
+ * Update the visible character counter and keep track of when the last update
2616
+ * happened for each keypress
2617
+ */
2618
+ CharacterCount.prototype.handleKeyUp = function () {
2619
+ this.updateVisibleCountMessage();
2620
+ this.lastInputTimestamp = Date.now();
2621
+ };
2622
+
2623
+ /**
2624
+ * Handle focus event
2625
+ *
2626
+ * Speech recognition software such as Dragon NaturallySpeaking will modify the
2627
+ * fields by directly changing its `value`. These changes don't trigger events
2628
+ * in JavaScript, so we need to poll to handle when and if they occur.
2629
+ *
2630
+ * Once the keyup event hasn't been detected for at least 1000 ms (1s), check if
2631
+ * the textarea value has changed and update the count message if it has.
2632
+ *
2633
+ * This is so that the update triggered by the manual comparison doesn't
2634
+ * conflict with debounced KeyboardEvent updates.
2635
+ */
2636
+ CharacterCount.prototype.handleFocus = function () {
2637
+ this.valueChecker = setInterval(function () {
2638
+ if (!this.lastInputTimestamp || (Date.now() - 500) >= this.lastInputTimestamp) {
2639
+ this.updateIfValueChanged();
2640
+ }
2641
+ }.bind(this), 1000);
2642
+ };
2643
+
2644
+ /**
2645
+ * Handle blur event
2646
+ *
2647
+ * Stop checking the textarea value once the textarea no longer has focus
2648
+ */
2649
+ CharacterCount.prototype.handleBlur = function () {
2650
+ // Cancel value checking on blur
2651
+ clearInterval(this.valueChecker);
2652
+ };
2653
+
2654
+ /**
2655
+ * Update count message if textarea value has changed
2656
+ */
2657
+ CharacterCount.prototype.updateIfValueChanged = function () {
1704
2658
  if (!this.$textarea.oldValue) this.$textarea.oldValue = '';
1705
2659
  if (this.$textarea.value !== this.$textarea.oldValue) {
1706
2660
  this.$textarea.oldValue = this.$textarea.value;
@@ -1708,14 +2662,20 @@ CharacterCount.prototype.checkIfValueChanged = function () {
1708
2662
  }
1709
2663
  };
1710
2664
 
1711
- // Helper function to update both the visible and screen reader-specific
1712
- // counters simultaneously (e.g. on init)
2665
+ /**
2666
+ * Update count message
2667
+ *
2668
+ * Helper function to update both the visible and screen reader-specific
2669
+ * counters simultaneously (e.g. on init)
2670
+ */
1713
2671
  CharacterCount.prototype.updateCountMessage = function () {
1714
2672
  this.updateVisibleCountMessage();
1715
2673
  this.updateScreenReaderCountMessage();
1716
2674
  };
1717
2675
 
1718
- // Update visible counter
2676
+ /**
2677
+ * Update visible count message
2678
+ */
1719
2679
  CharacterCount.prototype.updateVisibleCountMessage = function () {
1720
2680
  var $textarea = this.$textarea;
1721
2681
  var $visibleCountMessage = this.$visibleCountMessage;
@@ -1741,10 +2701,12 @@ CharacterCount.prototype.updateVisibleCountMessage = function () {
1741
2701
  }
1742
2702
 
1743
2703
  // Update message
1744
- $visibleCountMessage.innerHTML = this.formattedUpdateMessage();
2704
+ $visibleCountMessage.innerText = this.getCountMessage();
1745
2705
  };
1746
2706
 
1747
- // Update screen reader-specific counter
2707
+ /**
2708
+ * Update screen reader count message
2709
+ */
1748
2710
  CharacterCount.prototype.updateScreenReaderCountMessage = function () {
1749
2711
  var $screenReaderCountMessage = this.$screenReaderCountMessage;
1750
2712
 
@@ -1757,71 +2719,168 @@ CharacterCount.prototype.updateScreenReaderCountMessage = function () {
1757
2719
  }
1758
2720
 
1759
2721
  // Update message
1760
- $screenReaderCountMessage.innerHTML = this.formattedUpdateMessage();
2722
+ $screenReaderCountMessage.innerText = this.getCountMessage();
1761
2723
  };
1762
2724
 
1763
- // Format update message
1764
- CharacterCount.prototype.formattedUpdateMessage = function () {
1765
- var $textarea = this.$textarea;
1766
- var options = this.options;
1767
- var remainingNumber = this.maxLength - this.count($textarea.value);
2725
+ /**
2726
+ * Count the number of characters (or words, if `config.maxwords` is set)
2727
+ * in the given text
2728
+ *
2729
+ * @param {string} text - The text to count the characters of
2730
+ * @returns {number} the number of characters (or words) in the text
2731
+ */
2732
+ CharacterCount.prototype.count = function (text) {
2733
+ if (this.config.maxwords) {
2734
+ var tokens = text.match(/\S+/g) || []; // Matches consecutive non-whitespace chars
2735
+ return tokens.length
2736
+ } else {
2737
+ return text.length
2738
+ }
2739
+ };
2740
+
2741
+ /**
2742
+ * Get count message
2743
+ *
2744
+ * @returns {string} Status message
2745
+ */
2746
+ CharacterCount.prototype.getCountMessage = function () {
2747
+ var remainingNumber = this.maxLength - this.count(this.$textarea.value);
1768
2748
 
1769
- var charVerb = 'remaining';
1770
- var charNoun = 'character';
1771
- var displayNumber = remainingNumber;
1772
- if (options.maxwords) {
1773
- charNoun = 'word';
2749
+ var countType = this.config.maxwords ? 'words' : 'characters';
2750
+ return this.formatCountMessage(remainingNumber, countType)
2751
+ };
2752
+
2753
+ /**
2754
+ * Formats the message shown to users according to what's counted
2755
+ * and how many remain
2756
+ *
2757
+ * @param {number} remainingNumber - The number of words/characaters remaining
2758
+ * @param {string} countType - "words" or "characters"
2759
+ * @returns {string} Status message
2760
+ */
2761
+ CharacterCount.prototype.formatCountMessage = function (remainingNumber, countType) {
2762
+ if (remainingNumber === 0) {
2763
+ return this.i18n.t(countType + 'AtLimit')
1774
2764
  }
1775
- charNoun = charNoun + ((remainingNumber === -1 || remainingNumber === 1) ? '' : 's');
1776
2765
 
1777
- charVerb = (remainingNumber < 0) ? 'too many' : 'remaining';
1778
- displayNumber = Math.abs(remainingNumber);
2766
+ var translationKeySuffix = remainingNumber < 0 ? 'OverLimit' : 'UnderLimit';
1779
2767
 
1780
- return 'You have ' + displayNumber + ' ' + charNoun + ' ' + charVerb
2768
+ return this.i18n.t(countType + translationKeySuffix, { count: Math.abs(remainingNumber) })
1781
2769
  };
1782
2770
 
1783
- // Checks whether the value is over the configured threshold for the input.
1784
- // If there is no configured threshold, it is set to 0 and this function will
1785
- // always return true.
2771
+ /**
2772
+ * Check if count is over threshold
2773
+ *
2774
+ * Checks whether the value is over the configured threshold for the input.
2775
+ * If there is no configured threshold, it is set to 0 and this function will
2776
+ * always return true.
2777
+ *
2778
+ * @returns {boolean} true if the current count is over the config.threshold
2779
+ * (or no threshold is set)
2780
+ */
1786
2781
  CharacterCount.prototype.isOverThreshold = function () {
2782
+ // No threshold means we're always above threshold so save some computation
2783
+ if (!this.config.threshold) {
2784
+ return true
2785
+ }
2786
+
1787
2787
  var $textarea = this.$textarea;
1788
- var options = this.options;
1789
2788
 
1790
2789
  // Determine the remaining number of characters/words
1791
2790
  var currentLength = this.count($textarea.value);
1792
2791
  var maxLength = this.maxLength;
1793
2792
 
1794
- // Set threshold if presented in options
1795
- var thresholdPercent = options.threshold ? options.threshold : 0;
1796
- var thresholdValue = maxLength * thresholdPercent / 100;
2793
+ var thresholdValue = maxLength * this.config.threshold / 100;
1797
2794
 
1798
2795
  return (thresholdValue <= currentLength)
1799
2796
  };
1800
2797
 
1801
- // Update the visible character counter and keep track of when the last update
1802
- // happened for each keypress
1803
- CharacterCount.prototype.handleKeyUp = function () {
1804
- this.updateVisibleCountMessage();
1805
- this.lastInputTimestamp = Date.now();
1806
- };
2798
+ /**
2799
+ * Character count config
2800
+ *
2801
+ * @typedef {CharacterCountConfigWithMaxLength | CharacterCountConfigWithMaxWords} CharacterCountConfig
2802
+ */
1807
2803
 
1808
- CharacterCount.prototype.handleFocus = function () {
1809
- // If the field is focused, and a keyup event hasn't been detected for at
1810
- // least 1000 ms (1 second), then run the manual change check.
1811
- // This is so that the update triggered by the manual comparison doesn't
1812
- // conflict with debounced KeyboardEvent updates.
1813
- this.valueChecker = setInterval(function () {
1814
- if (!this.lastInputTimestamp || (Date.now() - 500) >= this.lastInputTimestamp) {
1815
- this.checkIfValueChanged();
1816
- }
1817
- }.bind(this), 1000);
1818
- };
2804
+ /**
2805
+ * Character count config (with maximum number of characters)
2806
+ *
2807
+ * @typedef {object} CharacterCountConfigWithMaxLength
2808
+ * @property {number} [maxlength] - The maximum number of characters.
2809
+ * If maxwords is provided, the maxlength option will be ignored.
2810
+ * @property {number} [threshold = 0] - The percentage value of the limit at
2811
+ * which point the count message is displayed. If this attribute is set, the
2812
+ * count message will be hidden by default.
2813
+ * @property {CharacterCountTranslations} [i18n = CHARACTER_COUNT_TRANSLATIONS] - See constant {@link CHARACTER_COUNT_TRANSLATIONS}
2814
+ */
1819
2815
 
1820
- CharacterCount.prototype.handleBlur = function () {
1821
- // Cancel value checking on blur
1822
- clearInterval(this.valueChecker);
1823
- };
2816
+ /**
2817
+ * Character count config (with maximum number of words)
2818
+ *
2819
+ * @typedef {object} CharacterCountConfigWithMaxWords
2820
+ * @property {number} [maxwords] - The maximum number of words. If maxwords is
2821
+ * provided, the maxlength option will be ignored.
2822
+ * @property {number} [threshold = 0] - The percentage value of the limit at
2823
+ * which point the count message is displayed. If this attribute is set, the
2824
+ * count message will be hidden by default.
2825
+ * @property {CharacterCountTranslations} [i18n = CHARACTER_COUNT_TRANSLATIONS] - See constant {@link CHARACTER_COUNT_TRANSLATIONS}
2826
+ */
2827
+
2828
+ /**
2829
+ * Character count translations
2830
+ *
2831
+ * @typedef {object} CharacterCountTranslations
2832
+ *
2833
+ * Messages shown to users as they type. It provides feedback on how many words
2834
+ * or characters they have remaining or if they are over the limit. This also
2835
+ * includes a message used as an accessible description for the textarea.
2836
+ * @property {TranslationPluralForms} [charactersUnderLimit] - Message displayed
2837
+ * when the number of characters is under the configured maximum, `maxlength`.
2838
+ * This message is displayed visually and through assistive technologies. The
2839
+ * component will replace the `%{count}` placeholder with the number of
2840
+ * remaining characters. This is a [pluralised list of
2841
+ * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
2842
+ * @property {string} [charactersAtLimit] - Message displayed when the number of
2843
+ * characters reaches the configured maximum, `maxlength`. This message is
2844
+ * displayed visually and through assistive technologies.
2845
+ * @property {TranslationPluralForms} [charactersOverLimit] - Message displayed
2846
+ * when the number of characters is over the configured maximum, `maxlength`.
2847
+ * This message is displayed visually and through assistive technologies. The
2848
+ * component will replace the `%{count}` placeholder with the number of
2849
+ * remaining characters. This is a [pluralised list of
2850
+ * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
2851
+ * @property {TranslationPluralForms} [wordsUnderLimit] - Message displayed when
2852
+ * the number of words is under the configured maximum, `maxlength`. This
2853
+ * message is displayed visually and through assistive technologies. The
2854
+ * component will replace the `%{count}` placeholder with the number of
2855
+ * remaining words. This is a [pluralised list of
2856
+ * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
2857
+ * @property {string} [wordsAtLimit] - Message displayed when the number of
2858
+ * words reaches the configured maximum, `maxlength`. This message is
2859
+ * displayed visually and through assistive technologies.
2860
+ * @property {TranslationPluralForms} [wordsOverLimit] - Message displayed when
2861
+ * the number of words is over the configured maximum, `maxlength`. This
2862
+ * message is displayed visually and through assistive technologies. The
2863
+ * component will replace the `%{count}` placeholder with the number of
2864
+ * remaining words. This is a [pluralised list of
2865
+ * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
2866
+ * @property {TranslationPluralForms} [textareaDescription] - Message made
2867
+ * available to assistive technologies, if none is already present in the
2868
+ * HTML, to describe that the component accepts only a limited amount of
2869
+ * content. It is visible on the page when JavaScript is unavailable. The
2870
+ * component will replace the `%{count}` placeholder with the value of the
2871
+ * `maxlength` or `maxwords` parameter.
2872
+ */
2873
+
2874
+ /**
2875
+ * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
2876
+ */
1824
2877
 
2878
+ /**
2879
+ * Checkboxes component
2880
+ *
2881
+ * @class
2882
+ * @param {HTMLElement} $module - HTML element to use for checkboxes
2883
+ */
1825
2884
  function Checkboxes ($module) {
1826
2885
  this.$module = $module;
1827
2886
  this.$inputs = $module.querySelectorAll('input[type="checkbox"]');
@@ -1891,7 +2950,7 @@ Checkboxes.prototype.syncAllConditionalReveals = function () {
1891
2950
  * Synchronise the visibility of the conditional reveal, and its accessible
1892
2951
  * state, with the input's checked state.
1893
2952
  *
1894
- * @param {HTMLInputElement} $input Checkbox input
2953
+ * @param {HTMLInputElement} $input - Checkbox input
1895
2954
  */
1896
2955
  Checkboxes.prototype.syncConditionalRevealWithInputState = function ($input) {
1897
2956
  var $target = document.getElementById($input.getAttribute('aria-controls'));
@@ -1949,7 +3008,7 @@ Checkboxes.prototype.unCheckExclusiveInputs = function ($input) {
1949
3008
  * Handle a click within the $module – if the click occurred on a checkbox, sync
1950
3009
  * the state of any associated conditional reveal with the checkbox state.
1951
3010
  *
1952
- * @param {MouseEvent} event Click event
3011
+ * @param {MouseEvent} event - Click event
1953
3012
  */
1954
3013
  Checkboxes.prototype.handleClick = function (event) {
1955
3014
  var $target = event.target;
@@ -1979,55 +3038,39 @@ Checkboxes.prototype.handleClick = function (event) {
1979
3038
  }
1980
3039
  };
1981
3040
 
1982
- (function(undefined) {
1983
-
1984
- // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/detect.js
1985
- var detect = (
1986
- 'document' in this && "matches" in document.documentElement
1987
- );
1988
-
1989
- if (detect) return
1990
-
1991
- // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/polyfill.js
1992
- Element.prototype.matches = Element.prototype.webkitMatchesSelector || Element.prototype.oMatchesSelector || Element.prototype.msMatchesSelector || Element.prototype.mozMatchesSelector || function matches(selector) {
1993
- var element = this;
1994
- var elements = (element.document || element.ownerDocument).querySelectorAll(selector);
1995
- var index = 0;
3041
+ /**
3042
+ * JavaScript enhancements for the ErrorSummary
3043
+ *
3044
+ * Takes focus on initialisation for accessible announcement, unless disabled in configuration.
3045
+ *
3046
+ * @class
3047
+ * @param {HTMLElement} $module - The element this component controls
3048
+ * @param {ErrorSummaryConfig} config - Error summary config
3049
+ */
3050
+ function ErrorSummary ($module, config) {
3051
+ // Some consuming code may not be passing a module,
3052
+ // for example if they initialise the component
3053
+ // on their own by directly passing the result
3054
+ // of `document.querySelector`.
3055
+ // To avoid breaking further JavaScript initialisation
3056
+ // we need to safeguard against this so things keep
3057
+ // working the same now we read the elements data attributes
3058
+ if (!$module) {
3059
+ // Little safety in case code gets ported as-is
3060
+ // into and ES6 class constructor, where the return value matters
3061
+ return this
3062
+ }
1996
3063
 
1997
- while (elements[index] && elements[index] !== element) {
1998
- ++index;
1999
- }
3064
+ this.$module = $module;
2000
3065
 
2001
- return !!elements[index];
3066
+ var defaultConfig = {
3067
+ disableAutoFocus: false
2002
3068
  };
2003
-
2004
- }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
2005
-
2006
- (function(undefined) {
2007
-
2008
- // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/detect.js
2009
- var detect = (
2010
- 'document' in this && "closest" in document.documentElement
3069
+ this.config = mergeConfigs(
3070
+ defaultConfig,
3071
+ config || {},
3072
+ normaliseDataset($module.dataset)
2011
3073
  );
2012
-
2013
- if (detect) return
2014
-
2015
- // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/polyfill.js
2016
- Element.prototype.closest = function closest(selector) {
2017
- var node = this;
2018
-
2019
- while (node) {
2020
- if (node.matches(selector)) return node;
2021
- else node = 'SVGElement' in window && node instanceof SVGElement ? node.parentNode : node.parentElement;
2022
- }
2023
-
2024
- return null;
2025
- };
2026
-
2027
- }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
2028
-
2029
- function ErrorSummary ($module) {
2030
- this.$module = $module;
2031
3074
  }
2032
3075
 
2033
3076
  ErrorSummary.prototype.init = function () {
@@ -2046,7 +3089,7 @@ ErrorSummary.prototype.init = function () {
2046
3089
  ErrorSummary.prototype.setFocus = function () {
2047
3090
  var $module = this.$module;
2048
3091
 
2049
- if ($module.getAttribute('data-disable-auto-focus') === 'true') {
3092
+ if (this.config.disableAutoFocus) {
2050
3093
  return
2051
3094
  }
2052
3095
 
@@ -2062,10 +3105,10 @@ ErrorSummary.prototype.setFocus = function () {
2062
3105
  };
2063
3106
 
2064
3107
  /**
2065
- * Click event handler
2066
- *
2067
- * @param {MouseEvent} event - Click event
2068
- */
3108
+ * Click event handler
3109
+ *
3110
+ * @param {MouseEvent} event - Click event
3111
+ */
2069
3112
  ErrorSummary.prototype.handleClick = function (event) {
2070
3113
  var target = event.target;
2071
3114
  if (this.focusTarget(target)) {
@@ -2189,8 +3232,32 @@ ErrorSummary.prototype.getAssociatedLegendOrLabel = function ($input) {
2189
3232
  $input.closest('label')
2190
3233
  };
2191
3234
 
2192
- function NotificationBanner ($module) {
3235
+ /**
3236
+ * Error summary config
3237
+ *
3238
+ * @typedef {object} ErrorSummaryConfig
3239
+ * @property {boolean} [disableAutoFocus = false] -
3240
+ * If set to `true` the error summary will not be focussed when the page loads.
3241
+ */
3242
+
3243
+ /**
3244
+ * Notification Banner component
3245
+ *
3246
+ * @class
3247
+ * @param {HTMLElement} $module - HTML element to use for notification banner
3248
+ * @param {NotificationBannerConfig} config - Notification banner config
3249
+ */
3250
+ function NotificationBanner ($module, config) {
2193
3251
  this.$module = $module;
3252
+
3253
+ var defaultConfig = {
3254
+ disableAutoFocus: false
3255
+ };
3256
+ this.config = mergeConfigs(
3257
+ defaultConfig,
3258
+ config || {},
3259
+ normaliseDataset($module.dataset)
3260
+ );
2194
3261
  }
2195
3262
 
2196
3263
  /**
@@ -2219,7 +3286,7 @@ NotificationBanner.prototype.init = function () {
2219
3286
  NotificationBanner.prototype.setFocus = function () {
2220
3287
  var $module = this.$module;
2221
3288
 
2222
- if ($module.getAttribute('data-disable-auto-focus') === 'true') {
3289
+ if (this.config.disableAutoFocus) {
2223
3290
  return
2224
3291
  }
2225
3292
 
@@ -2241,6 +3308,23 @@ NotificationBanner.prototype.setFocus = function () {
2241
3308
  $module.focus();
2242
3309
  };
2243
3310
 
3311
+ /**
3312
+ * Notification banner config
3313
+ *
3314
+ * @typedef {object} NotificationBannerConfig
3315
+ * @property {boolean} [disableAutoFocus = false] -
3316
+ * If set to `true` the notification banner will not be focussed when the page
3317
+ * loads. This only applies if the component has a `role` of `alert` – in
3318
+ * other cases the component will not be focused on page load, regardless of
3319
+ * this option.
3320
+ */
3321
+
3322
+ /**
3323
+ * Header component
3324
+ *
3325
+ * @class
3326
+ * @param {HTMLElement} $module - HTML element to use for header
3327
+ */
2244
3328
  function Header ($module) {
2245
3329
  this.$module = $module;
2246
3330
  this.$menuButton = $module && $module.querySelector('.govuk-js-header-toggle');
@@ -2330,6 +3414,12 @@ Header.prototype.handleMenuButtonClick = function () {
2330
3414
  this.syncState();
2331
3415
  };
2332
3416
 
3417
+ /**
3418
+ * Radios component
3419
+ *
3420
+ * @class
3421
+ * @param {HTMLElement} $module - HTML element to use for radios
3422
+ */
2333
3423
  function Radios ($module) {
2334
3424
  this.$module = $module;
2335
3425
  this.$inputs = $module.querySelectorAll('input[type="radio"]');
@@ -2400,7 +3490,7 @@ Radios.prototype.syncAllConditionalReveals = function () {
2400
3490
  * Synchronise the visibility of the conditional reveal, and its accessible
2401
3491
  * state, with the input's checked state.
2402
3492
  *
2403
- * @param {HTMLInputElement} $input Radio input
3493
+ * @param {HTMLInputElement} $input - Radio input
2404
3494
  */
2405
3495
  Radios.prototype.syncConditionalRevealWithInputState = function ($input) {
2406
3496
  var $target = document.getElementById($input.getAttribute('aria-controls'));
@@ -2421,7 +3511,7 @@ Radios.prototype.syncConditionalRevealWithInputState = function ($input) {
2421
3511
  * with the same name (because checking one radio could have un-checked a radio
2422
3512
  * in another $module)
2423
3513
  *
2424
- * @param {MouseEvent} event Click event
3514
+ * @param {MouseEvent} event - Click event
2425
3515
  */
2426
3516
  Radios.prototype.handleClick = function (event) {
2427
3517
  var $clickedInput = event.target;
@@ -2445,6 +3535,12 @@ Radios.prototype.handleClick = function (event) {
2445
3535
  }.bind(this));
2446
3536
  };
2447
3537
 
3538
+ /**
3539
+ * Skip link component
3540
+ *
3541
+ * @class
3542
+ * @param {HTMLElement} $module - HTML element to use for skip link
3543
+ */
2448
3544
  function SkipLink ($module) {
2449
3545
  this.$module = $module;
2450
3546
  this.$linkedElement = null;
@@ -2470,10 +3566,10 @@ SkipLink.prototype.init = function () {
2470
3566
  };
2471
3567
 
2472
3568
  /**
2473
- * Get linked element
2474
- *
2475
- * @returns {HTMLElement} $linkedElement - DOM element linked to from the skip link
2476
- */
3569
+ * Get linked element
3570
+ *
3571
+ * @returns {HTMLElement} $linkedElement - DOM element linked to from the skip link
3572
+ */
2477
3573
  SkipLink.prototype.getLinkedElement = function () {
2478
3574
  var linkedElementId = this.getFragmentFromUrl();
2479
3575
 
@@ -2574,6 +3670,12 @@ SkipLink.prototype.getFragmentFromUrl = function () {
2574
3670
 
2575
3671
  }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
2576
3672
 
3673
+ /**
3674
+ * Tabs component
3675
+ *
3676
+ * @class
3677
+ * @param {HTMLElement} $module - HTML element to use for tabs
3678
+ */
2577
3679
  function Tabs ($module) {
2578
3680
  this.$module = $module;
2579
3681
  this.$tabs = $module.querySelectorAll('.govuk-tabs__tab');
@@ -2848,67 +3950,90 @@ Tabs.prototype.getHref = function ($tab) {
2848
3950
  return hash
2849
3951
  };
2850
3952
 
2851
- function initAll (options) {
2852
- // Set the options to an empty object by default if no options are passed.
2853
- options = typeof options !== 'undefined' ? options : {};
3953
+ /**
3954
+ * Initialise all components
3955
+ *
3956
+ * Use the `data-module` attributes to find, instantiate and init all of the
3957
+ * components provided as part of GOV.UK Frontend.
3958
+ *
3959
+ * @param {Config} [config] - Config for all components
3960
+ */
3961
+ function initAll (config) {
3962
+ config = typeof config !== 'undefined' ? config : {};
2854
3963
 
2855
3964
  // Allow the user to initialise GOV.UK Frontend in only certain sections of the page
2856
3965
  // Defaults to the entire document if nothing is set.
2857
- var scope = typeof options.scope !== 'undefined' ? options.scope : document;
3966
+ var $scope = typeof config.scope !== 'undefined' ? config.scope : document;
2858
3967
 
2859
- var $buttons = scope.querySelectorAll('[data-module="govuk-button"]');
2860
- nodeListForEach($buttons, function ($button) {
2861
- new Button($button).init();
2862
- });
2863
-
2864
- var $accordions = scope.querySelectorAll('[data-module="govuk-accordion"]');
3968
+ var $accordions = $scope.querySelectorAll('[data-module="govuk-accordion"]');
2865
3969
  nodeListForEach($accordions, function ($accordion) {
2866
- new Accordion($accordion).init();
3970
+ new Accordion($accordion, config.accordion).init();
2867
3971
  });
2868
3972
 
2869
- var $details = scope.querySelectorAll('[data-module="govuk-details"]');
2870
- nodeListForEach($details, function ($detail) {
2871
- new Details($detail).init();
3973
+ var $buttons = $scope.querySelectorAll('[data-module="govuk-button"]');
3974
+ nodeListForEach($buttons, function ($button) {
3975
+ new Button($button, config.button).init();
2872
3976
  });
2873
3977
 
2874
- var $characterCounts = scope.querySelectorAll('[data-module="govuk-character-count"]');
3978
+ var $characterCounts = $scope.querySelectorAll('[data-module="govuk-character-count"]');
2875
3979
  nodeListForEach($characterCounts, function ($characterCount) {
2876
- new CharacterCount($characterCount).init();
3980
+ new CharacterCount($characterCount, config.characterCount).init();
2877
3981
  });
2878
3982
 
2879
- var $checkboxes = scope.querySelectorAll('[data-module="govuk-checkboxes"]');
3983
+ var $checkboxes = $scope.querySelectorAll('[data-module="govuk-checkboxes"]');
2880
3984
  nodeListForEach($checkboxes, function ($checkbox) {
2881
3985
  new Checkboxes($checkbox).init();
2882
3986
  });
2883
3987
 
3988
+ var $details = $scope.querySelectorAll('[data-module="govuk-details"]');
3989
+ nodeListForEach($details, function ($detail) {
3990
+ new Details($detail).init();
3991
+ });
3992
+
2884
3993
  // Find first error summary module to enhance.
2885
- var $errorSummary = scope.querySelector('[data-module="govuk-error-summary"]');
2886
- new ErrorSummary($errorSummary).init();
3994
+ var $errorSummary = $scope.querySelector('[data-module="govuk-error-summary"]');
3995
+ if ($errorSummary) {
3996
+ new ErrorSummary($errorSummary, config.errorSummary).init();
3997
+ }
2887
3998
 
2888
3999
  // Find first header module to enhance.
2889
- var $toggleButton = scope.querySelector('[data-module="govuk-header"]');
2890
- new Header($toggleButton).init();
4000
+ var $header = $scope.querySelector('[data-module="govuk-header"]');
4001
+ if ($header) {
4002
+ new Header($header).init();
4003
+ }
2891
4004
 
2892
- var $notificationBanners = scope.querySelectorAll('[data-module="govuk-notification-banner"]');
4005
+ var $notificationBanners = $scope.querySelectorAll('[data-module="govuk-notification-banner"]');
2893
4006
  nodeListForEach($notificationBanners, function ($notificationBanner) {
2894
- new NotificationBanner($notificationBanner).init();
4007
+ new NotificationBanner($notificationBanner, config.notificationBanner).init();
2895
4008
  });
2896
4009
 
2897
- var $radios = scope.querySelectorAll('[data-module="govuk-radios"]');
4010
+ var $radios = $scope.querySelectorAll('[data-module="govuk-radios"]');
2898
4011
  nodeListForEach($radios, function ($radio) {
2899
4012
  new Radios($radio).init();
2900
4013
  });
2901
4014
 
2902
4015
  // Find first skip link module to enhance.
2903
- var $skipLink = scope.querySelector('[data-module="govuk-skip-link"]');
4016
+ var $skipLink = $scope.querySelector('[data-module="govuk-skip-link"]');
2904
4017
  new SkipLink($skipLink).init();
2905
4018
 
2906
- var $tabs = scope.querySelectorAll('[data-module="govuk-tabs"]');
4019
+ var $tabs = $scope.querySelectorAll('[data-module="govuk-tabs"]');
2907
4020
  nodeListForEach($tabs, function ($tabs) {
2908
4021
  new Tabs($tabs).init();
2909
4022
  });
2910
4023
  }
2911
4024
 
4025
+ /**
4026
+ * Config for all components
4027
+ *
4028
+ * @typedef {object} Config
4029
+ * @property {HTMLElement} [scope=document] - Scope to query for components
4030
+ * @property {import('./components/accordion/accordion.mjs').AccordionConfig} [accordion] - Accordion config
4031
+ * @property {import('./components/button/button.mjs').ButtonConfig} [button] - Button config
4032
+ * @property {import('./components/character-count/character-count.mjs').CharacterCountConfig} [characterCount] - Character Count config
4033
+ * @property {import('./components/error-summary/error-summary.mjs').ErrorSummaryConfig} [errorSummary] - Error Summary config
4034
+ * @property {import('./components/notification-banner/notification-banner.mjs').NotificationBannerConfig} [notificationBanner] - Notification Banner config
4035
+ */
4036
+
2912
4037
  exports.initAll = initAll;
2913
4038
  exports.Accordion = Accordion;
2914
4039
  exports.Button = Button;