govuk_tech_docs 3.2.1 → 3.3.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/publish.yaml +1 -1
  3. data/CHANGELOG.md +26 -7
  4. data/README.md +2 -2
  5. data/lib/assets/javascripts/_modules/search.js +2 -2
  6. data/lib/govuk_tech_docs/contribution_banner.rb +1 -1
  7. data/lib/govuk_tech_docs/version.rb +1 -1
  8. data/lib/source/layouts/core.erb +1 -1
  9. data/node_modules/govuk-frontend/govuk/all.js +1548 -311
  10. data/node_modules/govuk-frontend/govuk/common/closest-attribute-value.js +70 -0
  11. data/node_modules/govuk-frontend/govuk/common/index.js +172 -0
  12. data/node_modules/govuk-frontend/govuk/common/normalise-dataset.js +373 -0
  13. data/node_modules/govuk-frontend/govuk/common.js +138 -3
  14. data/node_modules/govuk-frontend/govuk/components/_all.scss +1 -0
  15. data/node_modules/govuk-frontend/govuk/components/accordion/_index.scss +5 -6
  16. data/node_modules/govuk-frontend/govuk/components/accordion/accordion.js +754 -36
  17. data/node_modules/govuk-frontend/govuk/components/breadcrumbs/_index.scss +0 -2
  18. data/node_modules/govuk-frontend/govuk/components/button/_index.scss +29 -21
  19. data/node_modules/govuk-frontend/govuk/components/button/button.js +365 -107
  20. data/node_modules/govuk-frontend/govuk/components/character-count/_index.scss +9 -0
  21. data/node_modules/govuk-frontend/govuk/components/character-count/character-count.js +1092 -109
  22. data/node_modules/govuk-frontend/govuk/components/checkboxes/_index.scss +3 -2
  23. data/node_modules/govuk-frontend/govuk/components/checkboxes/checkboxes.js +30 -2
  24. data/node_modules/govuk-frontend/govuk/components/details/details.js +51 -33
  25. data/node_modules/govuk-frontend/govuk/components/error-summary/error-summary.js +289 -6
  26. data/node_modules/govuk-frontend/govuk/components/footer/_index.scss +13 -23
  27. data/node_modules/govuk-frontend/govuk/components/header/_index.scss +30 -24
  28. data/node_modules/govuk-frontend/govuk/components/header/header.js +59 -11
  29. data/node_modules/govuk-frontend/govuk/components/input/_index.scss +13 -23
  30. data/node_modules/govuk-frontend/govuk/components/notification-banner/notification-banner.js +252 -2
  31. data/node_modules/govuk-frontend/govuk/components/pagination/_index.scss +247 -0
  32. data/node_modules/govuk-frontend/govuk/components/pagination/_pagination.scss +2 -0
  33. data/node_modules/govuk-frontend/govuk/components/panel/_index.scss +1 -1
  34. data/node_modules/govuk-frontend/govuk/components/radios/_index.scss +5 -12
  35. data/node_modules/govuk-frontend/govuk/components/radios/radios.js +30 -2
  36. data/node_modules/govuk-frontend/govuk/components/select/_index.scss +11 -0
  37. data/node_modules/govuk-frontend/govuk/components/skip-link/_index.scss +1 -3
  38. data/node_modules/govuk-frontend/govuk/components/skip-link/skip-link.js +10 -4
  39. data/node_modules/govuk-frontend/govuk/components/summary-list/_index.scss +45 -13
  40. data/node_modules/govuk-frontend/govuk/components/table/_index.scss +1 -1
  41. data/node_modules/govuk-frontend/govuk/components/tabs/tabs.js +28 -0
  42. data/node_modules/govuk-frontend/govuk/core/_section-break.scss +1 -1
  43. data/node_modules/govuk-frontend/govuk/helpers/_colour.scss +5 -5
  44. data/node_modules/govuk-frontend/govuk/helpers/_focused.scss +5 -0
  45. data/node_modules/govuk-frontend/govuk/helpers/_links.scss +13 -11
  46. data/node_modules/govuk-frontend/govuk/helpers/_media-queries.scss +2 -2
  47. data/node_modules/govuk-frontend/govuk/helpers/_shape-arrow.scss +1 -1
  48. data/node_modules/govuk-frontend/govuk/helpers/_spacing.scss +3 -3
  49. data/node_modules/govuk-frontend/govuk/helpers/_typography.scss +16 -9
  50. data/node_modules/govuk-frontend/govuk/i18n.js +390 -0
  51. data/node_modules/govuk-frontend/govuk/objects/_button-group.scss +10 -26
  52. data/node_modules/govuk-frontend/govuk/objects/_template.scss +1 -1
  53. data/node_modules/govuk-frontend/govuk/objects/_width-container.scss +0 -4
  54. data/node_modules/govuk-frontend/govuk/overrides/_spacing.scss +56 -12
  55. data/node_modules/govuk-frontend/govuk/settings/_all.scss +1 -0
  56. data/node_modules/govuk-frontend/govuk/settings/_colours-palette.scss +12 -0
  57. data/node_modules/govuk-frontend/govuk/settings/_compatibility.scss +26 -0
  58. data/node_modules/govuk-frontend/govuk/settings/_spacing.scss +4 -8
  59. data/node_modules/govuk-frontend/govuk/settings/_typography-font.scss +23 -0
  60. data/node_modules/govuk-frontend/govuk/settings/_typography-responsive.scss +12 -0
  61. data/node_modules/govuk-frontend/govuk/settings/_warnings.scss +53 -0
  62. data/node_modules/govuk-frontend/govuk/tools/_compatibility.scss +20 -6
  63. data/node_modules/govuk-frontend/govuk/tools/_exports.scss +1 -1
  64. data/node_modules/govuk-frontend/govuk/tools/_font-url.scss +1 -1
  65. data/node_modules/govuk-frontend/govuk/tools/_image-url.scss +1 -1
  66. data/node_modules/govuk-frontend/govuk/tools/_px-to-em.scss +2 -2
  67. data/node_modules/govuk-frontend/govuk/tools/_px-to-rem.scss +1 -1
  68. data/node_modules/govuk-frontend/govuk/vendor/polyfills/Date/now.js +21 -0
  69. data/node_modules/govuk-frontend/govuk/vendor/polyfills/Element/prototype/dataset.js +300 -0
  70. data/node_modules/govuk-frontend/govuk/vendor/polyfills/String/prototype/trim.js +21 -0
  71. data/node_modules/govuk-frontend/govuk-prototype-kit/init.js +7 -0
  72. data/node_modules/govuk-frontend/govuk-prototype-kit/init.scss +12 -0
  73. data/package-lock.json +12 -12
  74. data/package.json +1 -1
  75. metadata +14 -2
@@ -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) {
@@ -1055,9 +1761,7 @@ var helper = {
1055
1761
  window.sessionStorage.removeItem(testString);
1056
1762
  return result
1057
1763
  } catch (exception) {
1058
- if ((typeof console === 'undefined' || typeof console.log === 'undefined')) {
1059
- console.log('Notice: sessionStorage not available.');
1060
- }
1764
+ return false
1061
1765
  }
1062
1766
  }
1063
1767
  };
@@ -1074,14 +1778,6 @@ Accordion.prototype.storeState = function ($section) {
1074
1778
  var contentId = $button.getAttribute('aria-controls');
1075
1779
  var contentState = $button.getAttribute('aria-expanded');
1076
1780
 
1077
- if (typeof contentId === 'undefined' && (typeof console === 'undefined' || typeof console.log === 'undefined')) {
1078
- console.error(new Error('No aria controls present in accordion section heading.'));
1079
- }
1080
-
1081
- if (typeof contentState === 'undefined' && (typeof console === 'undefined' || typeof console.log === 'undefined')) {
1082
- console.error(new Error('No aria expanded present in accordion section heading.'));
1083
- }
1084
-
1085
1781
  // Only set the state when both `contentId` and `contentState` are taken from the DOM.
1086
1782
  if (contentId && contentState) {
1087
1783
  window.sessionStorage.setItem(contentId, contentState);
@@ -1107,17 +1803,14 @@ Accordion.prototype.setInitialState = function ($section) {
1107
1803
  };
1108
1804
 
1109
1805
  /**
1110
- * Create an element to improve semantics of the section button with punctuation
1111
- * @return {object} DOM element
1112
- *
1113
- * Used to add pause (with a comma) for assistive technology.
1114
- * Example: [heading]Section A ,[pause] Show this section.
1115
- * https://accessibility.blog.gov.uk/2017/12/18/what-working-on-gov-uk-navigation-taught-us-about-accessibility/
1116
- *
1117
- * Adding punctuation to the button can also improve its general semantics by dividing its contents
1118
- * into thematic chunks.
1119
- * See https://github.com/alphagov/govuk-frontend/issues/2327#issuecomment-922957442
1120
- */
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
+ */
1121
1814
  Accordion.prototype.getButtonPunctuationEl = function () {
1122
1815
  var $punctuationEl = document.createElement('span');
1123
1816
  $punctuationEl.classList.add('govuk-visually-hidden', 'govuk-accordion__section-heading-divider');
@@ -1125,6 +1818,35 @@ Accordion.prototype.getButtonPunctuationEl = function () {
1125
1818
  return $punctuationEl
1126
1819
  };
1127
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
+
1128
1850
  (function(undefined) {
1129
1851
 
1130
1852
  // Detection from https://github.com/Financial-Times/polyfill-service/blob/master/packages/polyfill-library/polyfills/Window/detect.js
@@ -1398,44 +2120,79 @@ if (detect) return
1398
2120
  var KEY_SPACE = 32;
1399
2121
  var DEBOUNCE_TIMEOUT_IN_SECONDS = 1;
1400
2122
 
1401
- 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
+
1402
2135
  this.$module = $module;
1403
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
+ );
1404
2146
  }
1405
2147
 
1406
2148
  /**
1407
- * JavaScript 'shim' to trigger the click event of element(s) when the space key is pressed.
1408
- *
1409
- * Created since some Assistive Technologies (for example some Screenreaders)
1410
- * will tell a user to press space on a 'button', so this functionality needs to be shimmed
1411
- * See https://github.com/alphagov/govuk_elements/pull/272#issuecomment-233028270
1412
- *
1413
- * @param {object} event event
1414
- */
1415
- Button.prototype.handleKeyDown = function (event) {
1416
- // get the target element
1417
- var target = event.target;
1418
- // if the element has a role='button' and the pressed key is a space, we'll simulate a click
1419
- if (target.getAttribute('role') === 'button' && event.keyCode === KEY_SPACE) {
1420
- event.preventDefault();
1421
- // trigger the target's click event
1422
- target.click();
1423
- }
1424
- };
2149
+ * Initialise component
2150
+ */
2151
+ Button.prototype.init = function () {
2152
+ if (!this.$module) {
2153
+ return
2154
+ }
2155
+
2156
+ this.$module.addEventListener('keydown', this.handleKeyDown);
2157
+ this.$module.addEventListener('click', this.debounce.bind(this));
2158
+ };
1425
2159
 
1426
2160
  /**
1427
- * If the click quickly succeeds a previous click then nothing will happen.
1428
- * This stops people accidentally causing multiple form submissions by
1429
- * double clicking buttons.
1430
- */
1431
- 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) {
1432
2171
  var target = event.target;
1433
- // Check the button that is clicked on has the preventDoubleClick feature enabled
1434
- 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) {
1435
2192
  return
1436
2193
  }
1437
2194
 
1438
- // 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
1439
2196
  if (this.debounceFormSubmitTimer) {
1440
2197
  event.preventDefault();
1441
2198
  return false
@@ -1447,13 +2204,13 @@ Button.prototype.debounce = function (event) {
1447
2204
  };
1448
2205
 
1449
2206
  /**
1450
- * Initialise an event listener for keydown at document level
1451
- * this will help listening for later inserted elements with a role="button"
1452
- */
1453
- Button.prototype.init = function () {
1454
- this.$module.addEventListener('keydown', this.handleKeyDown);
1455
- this.$module.addEventListener('click', this.debounce);
1456
- };
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
+ */
1457
2214
 
1458
2215
  /**
1459
2216
  * JavaScript 'polyfill' for HTML5's <details> and <summary> elements
@@ -1465,6 +2222,12 @@ Button.prototype.init = function () {
1465
2222
  var KEY_ENTER = 13;
1466
2223
  var KEY_SPACE$1 = 32;
1467
2224
 
2225
+ /**
2226
+ * Details component
2227
+ *
2228
+ * @class
2229
+ * @param {HTMLElement} $module - HTML element to use for details
2230
+ */
1468
2231
  function Details ($module) {
1469
2232
  this.$module = $module;
1470
2233
  }
@@ -1519,13 +2282,10 @@ Details.prototype.polyfillDetails = function () {
1519
2282
  $summary.tabIndex = 0;
1520
2283
 
1521
2284
  // Detect initial open state
1522
- var openAttr = $module.getAttribute('open') !== null;
1523
- if (openAttr === true) {
2285
+ if (this.$module.hasAttribute('open')) {
1524
2286
  $summary.setAttribute('aria-expanded', 'true');
1525
- $content.setAttribute('aria-hidden', 'false');
1526
2287
  } else {
1527
2288
  $summary.setAttribute('aria-expanded', 'false');
1528
- $content.setAttribute('aria-hidden', 'true');
1529
2289
  $content.style.display = 'none';
1530
2290
  }
1531
2291
 
@@ -1534,37 +2294,30 @@ Details.prototype.polyfillDetails = function () {
1534
2294
  };
1535
2295
 
1536
2296
  /**
1537
- * Define a statechange function that updates aria-expanded and style.display
1538
- * @param {object} summary element
1539
- */
2297
+ * Define a statechange function that updates aria-expanded and style.display
2298
+ *
2299
+ * @returns {boolean} Returns true
2300
+ */
1540
2301
  Details.prototype.polyfillSetAttributes = function () {
1541
- var $module = this.$module;
1542
- var $summary = this.$summary;
1543
- var $content = this.$content;
1544
-
1545
- var expanded = $summary.getAttribute('aria-expanded') === 'true';
1546
- var hidden = $content.getAttribute('aria-hidden') === 'true';
1547
-
1548
- $summary.setAttribute('aria-expanded', (expanded ? 'false' : 'true'));
1549
- $content.setAttribute('aria-hidden', (hidden ? 'false' : 'true'));
1550
-
1551
- $content.style.display = (expanded ? 'none' : '');
1552
-
1553
- var hasOpenAttr = $module.getAttribute('open') !== null;
1554
- if (!hasOpenAttr) {
1555
- $module.setAttribute('open', 'open');
2302
+ if (this.$module.hasAttribute('open')) {
2303
+ this.$module.removeAttribute('open');
2304
+ this.$summary.setAttribute('aria-expanded', 'false');
2305
+ this.$content.style.display = 'none';
1556
2306
  } else {
1557
- $module.removeAttribute('open');
2307
+ this.$module.setAttribute('open', 'open');
2308
+ this.$summary.setAttribute('aria-expanded', 'true');
2309
+ this.$content.style.display = '';
1558
2310
  }
1559
2311
 
1560
2312
  return true
1561
2313
  };
1562
2314
 
1563
2315
  /**
1564
- * Handle cross-modal click events
1565
- * @param {object} node element
1566
- * @param {function} callback function
1567
- */
2316
+ * Handle cross-modal click events
2317
+ *
2318
+ * @param {object} node - element
2319
+ * @param {polyfillHandleInputsCallback} callback - function
2320
+ */
1568
2321
  Details.prototype.polyfillHandleInputs = function (node, callback) {
1569
2322
  node.addEventListener('keypress', function (event) {
1570
2323
  var target = event.target;
@@ -1598,114 +2351,310 @@ Details.prototype.polyfillHandleInputs = function (node, callback) {
1598
2351
  node.addEventListener('click', callback);
1599
2352
  };
1600
2353
 
1601
- function CharacterCount ($module) {
1602
- this.$module = $module;
1603
- this.$textarea = $module.querySelector('.govuk-js-character-count');
1604
- if (this.$textarea) {
1605
- this.$countMessage = document.getElementById(this.$textarea.id + '-info');
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)
1606
2432
  }
1607
2433
  }
1608
2434
 
1609
- CharacterCount.prototype.defaults = {
1610
- characterCountAttribute: 'data-maxlength',
1611
- wordCountAttribute: 'data-maxwords'
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
+ }
1612
2465
  };
1613
2466
 
1614
- // Initialize component
1615
- CharacterCount.prototype.init = function () {
1616
- // Check for module
1617
- var $module = this.$module;
1618
- var $textarea = this.$textarea;
1619
- var $countMessage = this.$countMessage;
1620
-
1621
- if (!$textarea || !$countMessage) {
1622
- return
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
1623
2484
  }
1624
2485
 
1625
- // We move count message right after the field
1626
- // Kept for backwards compatibility
1627
- $textarea.insertAdjacentElement('afterend', $countMessage);
2486
+ var defaultConfig = {
2487
+ threshold: 0,
2488
+ i18n: CHARACTER_COUNT_TRANSLATIONS
2489
+ };
1628
2490
 
1629
- // Read options set using dataset ('data-' values)
1630
- this.options = this.getDataset($module);
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
+ });
1631
2519
 
1632
2520
  // Determine the limit attribute (characters or words)
1633
- var countAttribute = this.defaults.characterCountAttribute;
1634
- if (this.options.maxwords) {
1635
- countAttribute = this.defaults.wordCountAttribute;
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
1636
2527
  }
1637
2528
 
1638
- // Save the element limit
1639
- this.maxLength = $module.getAttribute(countAttribute);
2529
+ this.$module = $module;
2530
+ this.$textarea = $module.querySelector('.govuk-js-character-count');
2531
+ this.$visibleCountMessage = null;
2532
+ this.$screenReaderCountMessage = null;
2533
+ this.lastInputTimestamp = null;
2534
+ }
1640
2535
 
1641
- // Check for limit
1642
- if (!this.maxLength) {
2536
+ /**
2537
+ * Initialise component
2538
+ */
2539
+ CharacterCount.prototype.init = function () {
2540
+ // Check that required elements are present
2541
+ if (!this.$textarea) {
1643
2542
  return
1644
2543
  }
1645
2544
 
1646
- // Remove hard limit if set
1647
- $module.removeAttribute('maxlength');
2545
+ var $textarea = this.$textarea;
2546
+ var $textareaDescription = document.getElementById($textarea.id + '-info');
1648
2547
 
1649
- // When the page is restored after navigating 'back' in some browsers the
1650
- // state of the character count is not restored until *after* the DOMContentLoaded
1651
- // event is fired, so we need to sync after the pageshow event in browsers
1652
- // that support it.
1653
- if ('onpageshow' in window) {
1654
- window.addEventListener('pageshow', this.sync.bind(this));
1655
- } else {
1656
- window.addEventListener('DOMContentLoaded', this.sync.bind(this));
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 });
1657
2553
  }
1658
2554
 
1659
- this.sync();
1660
- };
2555
+ // Move the textarea description to be immediately after the textarea
2556
+ // Kept for backwards compatibility
2557
+ $textarea.insertAdjacentElement('afterend', $textareaDescription);
2558
+
2559
+ // Create the *screen reader* specific live-updating counter
2560
+ // This doesn't need any styling classes, as it is never visible
2561
+ var $screenReaderCountMessage = document.createElement('div');
2562
+ $screenReaderCountMessage.className = 'govuk-character-count__sr-status govuk-visually-hidden';
2563
+ $screenReaderCountMessage.setAttribute('aria-live', 'polite');
2564
+ this.$screenReaderCountMessage = $screenReaderCountMessage;
2565
+ $textareaDescription.insertAdjacentElement('afterend', $screenReaderCountMessage);
2566
+
2567
+ // Create our live-updating counter element, copying the classes from the
2568
+ // textarea description for backwards compatibility as these may have been
2569
+ // configured
2570
+ var $visibleCountMessage = document.createElement('div');
2571
+ $visibleCountMessage.className = $textareaDescription.className;
2572
+ $visibleCountMessage.classList.add('govuk-character-count__status');
2573
+ $visibleCountMessage.setAttribute('aria-hidden', 'true');
2574
+ this.$visibleCountMessage = $visibleCountMessage;
2575
+ $textareaDescription.insertAdjacentElement('afterend', $visibleCountMessage);
2576
+
2577
+ // Hide the textarea description
2578
+ $textareaDescription.classList.add('govuk-visually-hidden');
1661
2579
 
1662
- CharacterCount.prototype.sync = function () {
1663
- this.bindChangeEvents();
1664
- this.updateCountMessage();
1665
- };
2580
+ // Remove hard limit if set
2581
+ $textarea.removeAttribute('maxlength');
1666
2582
 
1667
- // Read data attributes
1668
- CharacterCount.prototype.getDataset = function (element) {
1669
- var dataset = {};
1670
- var attributes = element.attributes;
1671
- if (attributes) {
1672
- for (var i = 0; i < attributes.length; i++) {
1673
- var attribute = attributes[i];
1674
- var match = attribute.name.match(/^data-(.+)/);
1675
- if (match) {
1676
- dataset[match[1]] = attribute.value;
1677
- }
1678
- }
1679
- }
1680
- return dataset
1681
- };
2583
+ this.bindChangeEvents();
1682
2584
 
1683
- // Counts characters or words in text
1684
- CharacterCount.prototype.count = function (text) {
1685
- var length;
1686
- if (this.options.maxwords) {
1687
- var tokens = text.match(/\S+/g) || []; // Matches consecutive non-whitespace chars
1688
- length = tokens.length;
2585
+ // When the page is restored after navigating 'back' in some browsers the
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.
2589
+ if ('onpageshow' in window) {
2590
+ window.addEventListener('pageshow', this.updateCountMessage.bind(this));
1689
2591
  } else {
1690
- length = text.length;
2592
+ window.addEventListener('DOMContentLoaded', this.updateCountMessage.bind(this));
1691
2593
  }
1692
- return length
2594
+ this.updateCountMessage();
1693
2595
  };
1694
2596
 
1695
- // 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
+ */
1696
2603
  CharacterCount.prototype.bindChangeEvents = function () {
1697
2604
  var $textarea = this.$textarea;
1698
- $textarea.addEventListener('keyup', this.checkIfValueChanged.bind(this));
2605
+ $textarea.addEventListener('keyup', this.handleKeyUp.bind(this));
1699
2606
 
1700
2607
  // Bind focus/blur events to start/stop polling
1701
2608
  $textarea.addEventListener('focus', this.handleFocus.bind(this));
1702
2609
  $textarea.addEventListener('blur', this.handleBlur.bind(this));
1703
2610
  };
1704
2611
 
1705
- // Speech recognition software such as Dragon NaturallySpeaking will modify the
1706
- // fields by directly changing its `value`. These changes don't trigger events
1707
- // in JavaScript, so we need to poll to handle when and if they occur.
1708
- 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 () {
1709
2658
  if (!this.$textarea.oldValue) this.$textarea.oldValue = '';
1710
2659
  if (this.$textarea.value !== this.$textarea.oldValue) {
1711
2660
  this.$textarea.oldValue = this.$textarea.value;
@@ -1713,66 +2662,225 @@ CharacterCount.prototype.checkIfValueChanged = function () {
1713
2662
  }
1714
2663
  };
1715
2664
 
1716
- // Update message box
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
+ */
1717
2671
  CharacterCount.prototype.updateCountMessage = function () {
1718
- var countElement = this.$textarea;
1719
- var options = this.options;
1720
- var countMessage = this.$countMessage;
2672
+ this.updateVisibleCountMessage();
2673
+ this.updateScreenReaderCountMessage();
2674
+ };
1721
2675
 
1722
- // Determine the remaining number of characters/words
1723
- var currentLength = this.count(countElement.value);
1724
- var maxLength = this.maxLength;
1725
- var remainingNumber = maxLength - currentLength;
1726
-
1727
- // Set threshold if presented in options
1728
- var thresholdPercent = options.threshold ? options.threshold : 0;
1729
- var thresholdValue = maxLength * thresholdPercent / 100;
1730
- if (thresholdValue > currentLength) {
1731
- countMessage.classList.add('govuk-character-count__message--disabled');
1732
- // Ensure threshold is hidden for users of assistive technologies
1733
- countMessage.setAttribute('aria-hidden', true);
2676
+ /**
2677
+ * Update visible count message
2678
+ */
2679
+ CharacterCount.prototype.updateVisibleCountMessage = function () {
2680
+ var $textarea = this.$textarea;
2681
+ var $visibleCountMessage = this.$visibleCountMessage;
2682
+ var remainingNumber = this.maxLength - this.count($textarea.value);
2683
+
2684
+ // If input is over the threshold, remove the disabled class which renders the
2685
+ // counter invisible.
2686
+ if (this.isOverThreshold()) {
2687
+ $visibleCountMessage.classList.remove('govuk-character-count__message--disabled');
1734
2688
  } else {
1735
- countMessage.classList.remove('govuk-character-count__message--disabled');
1736
- // Ensure threshold is visible for users of assistive technologies
1737
- countMessage.removeAttribute('aria-hidden');
2689
+ $visibleCountMessage.classList.add('govuk-character-count__message--disabled');
1738
2690
  }
1739
2691
 
1740
2692
  // Update styles
1741
2693
  if (remainingNumber < 0) {
1742
- countElement.classList.add('govuk-textarea--error');
1743
- countMessage.classList.remove('govuk-hint');
1744
- countMessage.classList.add('govuk-error-message');
2694
+ $textarea.classList.add('govuk-textarea--error');
2695
+ $visibleCountMessage.classList.remove('govuk-hint');
2696
+ $visibleCountMessage.classList.add('govuk-error-message');
1745
2697
  } else {
1746
- countElement.classList.remove('govuk-textarea--error');
1747
- countMessage.classList.remove('govuk-error-message');
1748
- countMessage.classList.add('govuk-hint');
2698
+ $textarea.classList.remove('govuk-textarea--error');
2699
+ $visibleCountMessage.classList.remove('govuk-error-message');
2700
+ $visibleCountMessage.classList.add('govuk-hint');
1749
2701
  }
1750
2702
 
1751
2703
  // Update message
1752
- var charVerb = 'remaining';
1753
- var charNoun = 'character';
1754
- var displayNumber = remainingNumber;
1755
- if (options.maxwords) {
1756
- charNoun = 'word';
2704
+ $visibleCountMessage.innerText = this.getCountMessage();
2705
+ };
2706
+
2707
+ /**
2708
+ * Update screen reader count message
2709
+ */
2710
+ CharacterCount.prototype.updateScreenReaderCountMessage = function () {
2711
+ var $screenReaderCountMessage = this.$screenReaderCountMessage;
2712
+
2713
+ // If over the threshold, remove the aria-hidden attribute, allowing screen
2714
+ // readers to announce the content of the element.
2715
+ if (this.isOverThreshold()) {
2716
+ $screenReaderCountMessage.removeAttribute('aria-hidden');
2717
+ } else {
2718
+ $screenReaderCountMessage.setAttribute('aria-hidden', true);
1757
2719
  }
1758
- charNoun = charNoun + ((remainingNumber === -1 || remainingNumber === 1) ? '' : 's');
1759
2720
 
1760
- charVerb = (remainingNumber < 0) ? 'too many' : 'remaining';
1761
- displayNumber = Math.abs(remainingNumber);
2721
+ // Update message
2722
+ $screenReaderCountMessage.innerText = this.getCountMessage();
2723
+ };
1762
2724
 
1763
- countMessage.innerHTML = 'You have ' + displayNumber + ' ' + charNoun + ' ' + charVerb;
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
+ }
1764
2739
  };
1765
2740
 
1766
- CharacterCount.prototype.handleFocus = function () {
1767
- // Check if value changed on focus
1768
- this.valueChecker = setInterval(this.checkIfValueChanged.bind(this), 1000);
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);
2748
+
2749
+ var countType = this.config.maxwords ? 'words' : 'characters';
2750
+ return this.formatCountMessage(remainingNumber, countType)
1769
2751
  };
1770
2752
 
1771
- CharacterCount.prototype.handleBlur = function () {
1772
- // Cancel value checking on blur
1773
- clearInterval(this.valueChecker);
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')
2764
+ }
2765
+
2766
+ var translationKeySuffix = remainingNumber < 0 ? 'OverLimit' : 'UnderLimit';
2767
+
2768
+ return this.i18n.t(countType + translationKeySuffix, { count: Math.abs(remainingNumber) })
2769
+ };
2770
+
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
+ */
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
+
2787
+ var $textarea = this.$textarea;
2788
+
2789
+ // Determine the remaining number of characters/words
2790
+ var currentLength = this.count($textarea.value);
2791
+ var maxLength = this.maxLength;
2792
+
2793
+ var thresholdValue = maxLength * this.config.threshold / 100;
2794
+
2795
+ return (thresholdValue <= currentLength)
1774
2796
  };
1775
2797
 
2798
+ /**
2799
+ * Character count config
2800
+ *
2801
+ * @typedef {CharacterCountConfigWithMaxLength | CharacterCountConfigWithMaxWords} CharacterCountConfig
2802
+ */
2803
+
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
+ */
2815
+
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
+ */
2877
+
2878
+ /**
2879
+ * Checkboxes component
2880
+ *
2881
+ * @class
2882
+ * @param {HTMLElement} $module - HTML element to use for checkboxes
2883
+ */
1776
2884
  function Checkboxes ($module) {
1777
2885
  this.$module = $module;
1778
2886
  this.$inputs = $module.querySelectorAll('input[type="checkbox"]');
@@ -1842,7 +2950,7 @@ Checkboxes.prototype.syncAllConditionalReveals = function () {
1842
2950
  * Synchronise the visibility of the conditional reveal, and its accessible
1843
2951
  * state, with the input's checked state.
1844
2952
  *
1845
- * @param {HTMLInputElement} $input Checkbox input
2953
+ * @param {HTMLInputElement} $input - Checkbox input
1846
2954
  */
1847
2955
  Checkboxes.prototype.syncConditionalRevealWithInputState = function ($input) {
1848
2956
  var $target = document.getElementById($input.getAttribute('aria-controls'));
@@ -1900,7 +3008,7 @@ Checkboxes.prototype.unCheckExclusiveInputs = function ($input) {
1900
3008
  * Handle a click within the $module – if the click occurred on a checkbox, sync
1901
3009
  * the state of any associated conditional reveal with the checkbox state.
1902
3010
  *
1903
- * @param {MouseEvent} event Click event
3011
+ * @param {MouseEvent} event - Click event
1904
3012
  */
1905
3013
  Checkboxes.prototype.handleClick = function (event) {
1906
3014
  var $target = event.target;
@@ -1930,55 +3038,39 @@ Checkboxes.prototype.handleClick = function (event) {
1930
3038
  }
1931
3039
  };
1932
3040
 
1933
- (function(undefined) {
1934
-
1935
- // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/detect.js
1936
- var detect = (
1937
- 'document' in this && "matches" in document.documentElement
1938
- );
1939
-
1940
- if (detect) return
1941
-
1942
- // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/polyfill.js
1943
- Element.prototype.matches = Element.prototype.webkitMatchesSelector || Element.prototype.oMatchesSelector || Element.prototype.msMatchesSelector || Element.prototype.mozMatchesSelector || function matches(selector) {
1944
- var element = this;
1945
- var elements = (element.document || element.ownerDocument).querySelectorAll(selector);
1946
- 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
+ }
1947
3063
 
1948
- while (elements[index] && elements[index] !== element) {
1949
- ++index;
1950
- }
3064
+ this.$module = $module;
1951
3065
 
1952
- return !!elements[index];
3066
+ var defaultConfig = {
3067
+ disableAutoFocus: false
1953
3068
  };
1954
-
1955
- }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
1956
-
1957
- (function(undefined) {
1958
-
1959
- // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/detect.js
1960
- var detect = (
1961
- 'document' in this && "closest" in document.documentElement
3069
+ this.config = mergeConfigs(
3070
+ defaultConfig,
3071
+ config || {},
3072
+ normaliseDataset($module.dataset)
1962
3073
  );
1963
-
1964
- if (detect) return
1965
-
1966
- // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/polyfill.js
1967
- Element.prototype.closest = function closest(selector) {
1968
- var node = this;
1969
-
1970
- while (node) {
1971
- if (node.matches(selector)) return node;
1972
- else node = 'SVGElement' in window && node instanceof SVGElement ? node.parentNode : node.parentElement;
1973
- }
1974
-
1975
- return null;
1976
- };
1977
-
1978
- }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
1979
-
1980
- function ErrorSummary ($module) {
1981
- this.$module = $module;
1982
3074
  }
1983
3075
 
1984
3076
  ErrorSummary.prototype.init = function () {
@@ -1986,16 +3078,37 @@ ErrorSummary.prototype.init = function () {
1986
3078
  if (!$module) {
1987
3079
  return
1988
3080
  }
1989
- $module.focus();
1990
3081
 
3082
+ this.setFocus();
1991
3083
  $module.addEventListener('click', this.handleClick.bind(this));
1992
3084
  };
1993
3085
 
1994
3086
  /**
1995
- * Click event handler
1996
- *
1997
- * @param {MouseEvent} event - Click event
1998
- */
3087
+ * Focus the error summary
3088
+ */
3089
+ ErrorSummary.prototype.setFocus = function () {
3090
+ var $module = this.$module;
3091
+
3092
+ if (this.config.disableAutoFocus) {
3093
+ return
3094
+ }
3095
+
3096
+ // Set tabindex to -1 to make the element programmatically focusable, but
3097
+ // remove it on blur as the error summary doesn't need to be focused again.
3098
+ $module.setAttribute('tabindex', '-1');
3099
+
3100
+ $module.addEventListener('blur', function () {
3101
+ $module.removeAttribute('tabindex');
3102
+ });
3103
+
3104
+ $module.focus();
3105
+ };
3106
+
3107
+ /**
3108
+ * Click event handler
3109
+ *
3110
+ * @param {MouseEvent} event - Click event
3111
+ */
1999
3112
  ErrorSummary.prototype.handleClick = function (event) {
2000
3113
  var target = event.target;
2001
3114
  if (this.focusTarget(target)) {
@@ -2119,8 +3232,32 @@ ErrorSummary.prototype.getAssociatedLegendOrLabel = function ($input) {
2119
3232
  $input.closest('label')
2120
3233
  };
2121
3234
 
2122
- 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) {
2123
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
+ );
2124
3261
  }
2125
3262
 
2126
3263
  /**
@@ -2149,7 +3286,7 @@ NotificationBanner.prototype.init = function () {
2149
3286
  NotificationBanner.prototype.setFocus = function () {
2150
3287
  var $module = this.$module;
2151
3288
 
2152
- if ($module.getAttribute('data-disable-auto-focus') === 'true') {
3289
+ if (this.config.disableAutoFocus) {
2153
3290
  return
2154
3291
  }
2155
3292
 
@@ -2171,12 +3308,40 @@ NotificationBanner.prototype.setFocus = function () {
2171
3308
  $module.focus();
2172
3309
  };
2173
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
+ */
2174
3328
  function Header ($module) {
2175
3329
  this.$module = $module;
2176
3330
  this.$menuButton = $module && $module.querySelector('.govuk-js-header-toggle');
2177
3331
  this.$menu = this.$menuButton && $module.querySelector(
2178
3332
  '#' + this.$menuButton.getAttribute('aria-controls')
2179
3333
  );
3334
+
3335
+ // Save the opened/closed state for the nav in memory so that we can
3336
+ // accurately maintain state when the screen is changed from small to
3337
+ // big and back to small
3338
+ this.menuIsOpen = false;
3339
+
3340
+ // A global const for storing a matchMedia instance which we'll use to
3341
+ // detect when a screen size change happens. We set this later during the
3342
+ // init function and rely on it being null if the feature isn't available
3343
+ // to initially apply hidden attributes
3344
+ this.mql = null;
2180
3345
  }
2181
3346
 
2182
3347
  /**
@@ -2184,27 +3349,58 @@ function Header ($module) {
2184
3349
  *
2185
3350
  * Check for the presence of the header, menu and menu button – if any are
2186
3351
  * missing then there's nothing to do so return early.
3352
+ * Feature sniff for and apply a matchMedia for desktop which will
3353
+ * trigger a state sync if the browser viewport moves between states. If
3354
+ * matchMedia isn't available, hide the menu button and present the "no js"
3355
+ * version of the menu to the user.
2187
3356
  */
2188
3357
  Header.prototype.init = function () {
2189
3358
  if (!this.$module || !this.$menuButton || !this.$menu) {
2190
3359
  return
2191
3360
  }
2192
3361
 
2193
- this.syncState(this.$menu.classList.contains('govuk-header__navigation-list--open'));
2194
- this.$menuButton.addEventListener('click', this.handleMenuButtonClick.bind(this));
3362
+ if ('matchMedia' in window) {
3363
+ // Set the matchMedia to the govuk-frontend desktop breakpoint
3364
+ this.mql = window.matchMedia('(min-width: 48.0625em)');
3365
+
3366
+ if ('addEventListener' in this.mql) {
3367
+ this.mql.addEventListener('change', this.syncState.bind(this));
3368
+ } else {
3369
+ // addListener is a deprecated function, however addEventListener
3370
+ // isn't supported by IE or Safari. We therefore add this in as
3371
+ // a fallback for those browsers
3372
+ this.mql.addListener(this.syncState.bind(this));
3373
+ }
3374
+
3375
+ this.syncState();
3376
+ this.$menuButton.addEventListener('click', this.handleMenuButtonClick.bind(this));
3377
+ } else {
3378
+ this.$menuButton.setAttribute('hidden', '');
3379
+ }
2195
3380
  };
2196
3381
 
2197
3382
  /**
2198
3383
  * Sync menu state
2199
3384
  *
2200
- * Sync the menu button class and the accessible state of the menu and the menu
2201
- * button with the visible state of the menu
2202
- *
2203
- * @param {boolean} isVisible Whether the menu is currently visible
3385
+ * Uses the global variable menuIsOpen to correctly set the accessible and
3386
+ * visual states of the menu and the menu button.
3387
+ * Additionally will force the menu to be visible and the menu button to be
3388
+ * hidden if the matchMedia is triggered to desktop.
2204
3389
  */
2205
- Header.prototype.syncState = function (isVisible) {
2206
- this.$menuButton.classList.toggle('govuk-header__menu-button--open', isVisible);
2207
- this.$menuButton.setAttribute('aria-expanded', isVisible);
3390
+ Header.prototype.syncState = function () {
3391
+ if (this.mql.matches) {
3392
+ this.$menu.removeAttribute('hidden');
3393
+ this.$menuButton.setAttribute('hidden', '');
3394
+ } else {
3395
+ this.$menuButton.removeAttribute('hidden');
3396
+ this.$menuButton.setAttribute('aria-expanded', this.menuIsOpen);
3397
+
3398
+ if (this.menuIsOpen) {
3399
+ this.$menu.removeAttribute('hidden');
3400
+ } else {
3401
+ this.$menu.setAttribute('hidden', '');
3402
+ }
3403
+ }
2208
3404
  };
2209
3405
 
2210
3406
  /**
@@ -2214,10 +3410,16 @@ Header.prototype.syncState = function (isVisible) {
2214
3410
  * sync the accessibility state and menu button state
2215
3411
  */
2216
3412
  Header.prototype.handleMenuButtonClick = function () {
2217
- var isVisible = this.$menu.classList.toggle('govuk-header__navigation-list--open');
2218
- this.syncState(isVisible);
3413
+ this.menuIsOpen = !this.menuIsOpen;
3414
+ this.syncState();
2219
3415
  };
2220
3416
 
3417
+ /**
3418
+ * Radios component
3419
+ *
3420
+ * @class
3421
+ * @param {HTMLElement} $module - HTML element to use for radios
3422
+ */
2221
3423
  function Radios ($module) {
2222
3424
  this.$module = $module;
2223
3425
  this.$inputs = $module.querySelectorAll('input[type="radio"]');
@@ -2288,7 +3490,7 @@ Radios.prototype.syncAllConditionalReveals = function () {
2288
3490
  * Synchronise the visibility of the conditional reveal, and its accessible
2289
3491
  * state, with the input's checked state.
2290
3492
  *
2291
- * @param {HTMLInputElement} $input Radio input
3493
+ * @param {HTMLInputElement} $input - Radio input
2292
3494
  */
2293
3495
  Radios.prototype.syncConditionalRevealWithInputState = function ($input) {
2294
3496
  var $target = document.getElementById($input.getAttribute('aria-controls'));
@@ -2309,7 +3511,7 @@ Radios.prototype.syncConditionalRevealWithInputState = function ($input) {
2309
3511
  * with the same name (because checking one radio could have un-checked a radio
2310
3512
  * in another $module)
2311
3513
  *
2312
- * @param {MouseEvent} event Click event
3514
+ * @param {MouseEvent} event - Click event
2313
3515
  */
2314
3516
  Radios.prototype.handleClick = function (event) {
2315
3517
  var $clickedInput = event.target;
@@ -2333,6 +3535,12 @@ Radios.prototype.handleClick = function (event) {
2333
3535
  }.bind(this));
2334
3536
  };
2335
3537
 
3538
+ /**
3539
+ * Skip link component
3540
+ *
3541
+ * @class
3542
+ * @param {HTMLElement} $module - HTML element to use for skip link
3543
+ */
2336
3544
  function SkipLink ($module) {
2337
3545
  this.$module = $module;
2338
3546
  this.$linkedElement = null;
@@ -2358,10 +3566,10 @@ SkipLink.prototype.init = function () {
2358
3566
  };
2359
3567
 
2360
3568
  /**
2361
- * Get linked element
2362
- *
2363
- * @returns {HTMLElement} $linkedElement - DOM element linked to from the skip link
2364
- */
3569
+ * Get linked element
3570
+ *
3571
+ * @returns {HTMLElement} $linkedElement - DOM element linked to from the skip link
3572
+ */
2365
3573
  SkipLink.prototype.getLinkedElement = function () {
2366
3574
  var linkedElementId = this.getFragmentFromUrl();
2367
3575
 
@@ -2462,6 +3670,12 @@ SkipLink.prototype.getFragmentFromUrl = function () {
2462
3670
 
2463
3671
  }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
2464
3672
 
3673
+ /**
3674
+ * Tabs component
3675
+ *
3676
+ * @class
3677
+ * @param {HTMLElement} $module - HTML element to use for tabs
3678
+ */
2465
3679
  function Tabs ($module) {
2466
3680
  this.$module = $module;
2467
3681
  this.$tabs = $module.querySelectorAll('.govuk-tabs__tab');
@@ -2736,67 +3950,90 @@ Tabs.prototype.getHref = function ($tab) {
2736
3950
  return hash
2737
3951
  };
2738
3952
 
2739
- function initAll (options) {
2740
- // Set the options to an empty object by default if no options are passed.
2741
- 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 : {};
2742
3963
 
2743
3964
  // Allow the user to initialise GOV.UK Frontend in only certain sections of the page
2744
3965
  // Defaults to the entire document if nothing is set.
2745
- var scope = typeof options.scope !== 'undefined' ? options.scope : document;
2746
-
2747
- var $buttons = scope.querySelectorAll('[data-module="govuk-button"]');
2748
- nodeListForEach($buttons, function ($button) {
2749
- new Button($button).init();
2750
- });
3966
+ var $scope = typeof config.scope !== 'undefined' ? config.scope : document;
2751
3967
 
2752
- var $accordions = scope.querySelectorAll('[data-module="govuk-accordion"]');
3968
+ var $accordions = $scope.querySelectorAll('[data-module="govuk-accordion"]');
2753
3969
  nodeListForEach($accordions, function ($accordion) {
2754
- new Accordion($accordion).init();
3970
+ new Accordion($accordion, config.accordion).init();
2755
3971
  });
2756
3972
 
2757
- var $details = scope.querySelectorAll('[data-module="govuk-details"]');
2758
- nodeListForEach($details, function ($detail) {
2759
- 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();
2760
3976
  });
2761
3977
 
2762
- var $characterCounts = scope.querySelectorAll('[data-module="govuk-character-count"]');
3978
+ var $characterCounts = $scope.querySelectorAll('[data-module="govuk-character-count"]');
2763
3979
  nodeListForEach($characterCounts, function ($characterCount) {
2764
- new CharacterCount($characterCount).init();
3980
+ new CharacterCount($characterCount, config.characterCount).init();
2765
3981
  });
2766
3982
 
2767
- var $checkboxes = scope.querySelectorAll('[data-module="govuk-checkboxes"]');
3983
+ var $checkboxes = $scope.querySelectorAll('[data-module="govuk-checkboxes"]');
2768
3984
  nodeListForEach($checkboxes, function ($checkbox) {
2769
3985
  new Checkboxes($checkbox).init();
2770
3986
  });
2771
3987
 
3988
+ var $details = $scope.querySelectorAll('[data-module="govuk-details"]');
3989
+ nodeListForEach($details, function ($detail) {
3990
+ new Details($detail).init();
3991
+ });
3992
+
2772
3993
  // Find first error summary module to enhance.
2773
- var $errorSummary = scope.querySelector('[data-module="govuk-error-summary"]');
2774
- 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
+ }
2775
3998
 
2776
3999
  // Find first header module to enhance.
2777
- var $toggleButton = scope.querySelector('[data-module="govuk-header"]');
2778
- new Header($toggleButton).init();
4000
+ var $header = $scope.querySelector('[data-module="govuk-header"]');
4001
+ if ($header) {
4002
+ new Header($header).init();
4003
+ }
2779
4004
 
2780
- var $notificationBanners = scope.querySelectorAll('[data-module="govuk-notification-banner"]');
4005
+ var $notificationBanners = $scope.querySelectorAll('[data-module="govuk-notification-banner"]');
2781
4006
  nodeListForEach($notificationBanners, function ($notificationBanner) {
2782
- new NotificationBanner($notificationBanner).init();
4007
+ new NotificationBanner($notificationBanner, config.notificationBanner).init();
2783
4008
  });
2784
4009
 
2785
- var $radios = scope.querySelectorAll('[data-module="govuk-radios"]');
4010
+ var $radios = $scope.querySelectorAll('[data-module="govuk-radios"]');
2786
4011
  nodeListForEach($radios, function ($radio) {
2787
4012
  new Radios($radio).init();
2788
4013
  });
2789
4014
 
2790
4015
  // Find first skip link module to enhance.
2791
- var $skipLink = scope.querySelector('[data-module="govuk-skip-link"]');
4016
+ var $skipLink = $scope.querySelector('[data-module="govuk-skip-link"]');
2792
4017
  new SkipLink($skipLink).init();
2793
4018
 
2794
- var $tabs = scope.querySelectorAll('[data-module="govuk-tabs"]');
4019
+ var $tabs = $scope.querySelectorAll('[data-module="govuk-tabs"]');
2795
4020
  nodeListForEach($tabs, function ($tabs) {
2796
4021
  new Tabs($tabs).init();
2797
4022
  });
2798
4023
  }
2799
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
+
2800
4037
  exports.initAll = initAll;
2801
4038
  exports.Accordion = Accordion;
2802
4039
  exports.Button = Button;