govuk_tech_docs 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) 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/example/source/code.html.md +3 -26
  6. data/lib/govuk_tech_docs/contribution_banner.rb +1 -1
  7. data/lib/govuk_tech_docs/tech_docs_html_renderer.rb +3 -3
  8. data/lib/govuk_tech_docs/version.rb +1 -1
  9. data/lib/source/layouts/core.erb +1 -1
  10. data/node_modules/govuk-frontend/govuk/all.js +1548 -311
  11. data/node_modules/govuk-frontend/govuk/common/closest-attribute-value.js +70 -0
  12. data/node_modules/govuk-frontend/govuk/common/index.js +172 -0
  13. data/node_modules/govuk-frontend/govuk/common/normalise-dataset.js +373 -0
  14. data/node_modules/govuk-frontend/govuk/common.js +138 -3
  15. data/node_modules/govuk-frontend/govuk/components/_all.scss +1 -0
  16. data/node_modules/govuk-frontend/govuk/components/accordion/_index.scss +5 -6
  17. data/node_modules/govuk-frontend/govuk/components/accordion/accordion.js +754 -36
  18. data/node_modules/govuk-frontend/govuk/components/breadcrumbs/_index.scss +0 -2
  19. data/node_modules/govuk-frontend/govuk/components/button/_index.scss +29 -21
  20. data/node_modules/govuk-frontend/govuk/components/button/button.js +365 -107
  21. data/node_modules/govuk-frontend/govuk/components/character-count/_index.scss +9 -0
  22. data/node_modules/govuk-frontend/govuk/components/character-count/character-count.js +1092 -109
  23. data/node_modules/govuk-frontend/govuk/components/checkboxes/_index.scss +3 -2
  24. data/node_modules/govuk-frontend/govuk/components/checkboxes/checkboxes.js +30 -2
  25. data/node_modules/govuk-frontend/govuk/components/details/details.js +51 -33
  26. data/node_modules/govuk-frontend/govuk/components/error-summary/error-summary.js +289 -6
  27. data/node_modules/govuk-frontend/govuk/components/footer/_index.scss +13 -23
  28. data/node_modules/govuk-frontend/govuk/components/header/_index.scss +30 -24
  29. data/node_modules/govuk-frontend/govuk/components/header/header.js +59 -11
  30. data/node_modules/govuk-frontend/govuk/components/input/_index.scss +13 -23
  31. data/node_modules/govuk-frontend/govuk/components/notification-banner/notification-banner.js +252 -2
  32. data/node_modules/govuk-frontend/govuk/components/pagination/_index.scss +247 -0
  33. data/node_modules/govuk-frontend/govuk/components/pagination/_pagination.scss +2 -0
  34. data/node_modules/govuk-frontend/govuk/components/panel/_index.scss +1 -1
  35. data/node_modules/govuk-frontend/govuk/components/radios/_index.scss +5 -12
  36. data/node_modules/govuk-frontend/govuk/components/radios/radios.js +30 -2
  37. data/node_modules/govuk-frontend/govuk/components/select/_index.scss +11 -0
  38. data/node_modules/govuk-frontend/govuk/components/skip-link/_index.scss +1 -3
  39. data/node_modules/govuk-frontend/govuk/components/skip-link/skip-link.js +10 -4
  40. data/node_modules/govuk-frontend/govuk/components/summary-list/_index.scss +45 -13
  41. data/node_modules/govuk-frontend/govuk/components/table/_index.scss +1 -1
  42. data/node_modules/govuk-frontend/govuk/components/tabs/tabs.js +28 -0
  43. data/node_modules/govuk-frontend/govuk/core/_section-break.scss +1 -1
  44. data/node_modules/govuk-frontend/govuk/helpers/_colour.scss +5 -5
  45. data/node_modules/govuk-frontend/govuk/helpers/_focused.scss +5 -0
  46. data/node_modules/govuk-frontend/govuk/helpers/_links.scss +13 -11
  47. data/node_modules/govuk-frontend/govuk/helpers/_media-queries.scss +2 -2
  48. data/node_modules/govuk-frontend/govuk/helpers/_shape-arrow.scss +1 -1
  49. data/node_modules/govuk-frontend/govuk/helpers/_spacing.scss +3 -3
  50. data/node_modules/govuk-frontend/govuk/helpers/_typography.scss +16 -9
  51. data/node_modules/govuk-frontend/govuk/i18n.js +390 -0
  52. data/node_modules/govuk-frontend/govuk/objects/_button-group.scss +10 -26
  53. data/node_modules/govuk-frontend/govuk/objects/_template.scss +1 -1
  54. data/node_modules/govuk-frontend/govuk/objects/_width-container.scss +0 -4
  55. data/node_modules/govuk-frontend/govuk/overrides/_spacing.scss +56 -12
  56. data/node_modules/govuk-frontend/govuk/settings/_all.scss +1 -0
  57. data/node_modules/govuk-frontend/govuk/settings/_colours-palette.scss +12 -0
  58. data/node_modules/govuk-frontend/govuk/settings/_compatibility.scss +26 -0
  59. data/node_modules/govuk-frontend/govuk/settings/_spacing.scss +4 -8
  60. data/node_modules/govuk-frontend/govuk/settings/_typography-font.scss +23 -0
  61. data/node_modules/govuk-frontend/govuk/settings/_typography-responsive.scss +12 -0
  62. data/node_modules/govuk-frontend/govuk/settings/_warnings.scss +53 -0
  63. data/node_modules/govuk-frontend/govuk/tools/_compatibility.scss +20 -6
  64. data/node_modules/govuk-frontend/govuk/tools/_exports.scss +1 -1
  65. data/node_modules/govuk-frontend/govuk/tools/_font-url.scss +1 -1
  66. data/node_modules/govuk-frontend/govuk/tools/_image-url.scss +1 -1
  67. data/node_modules/govuk-frontend/govuk/tools/_px-to-em.scss +2 -2
  68. data/node_modules/govuk-frontend/govuk/tools/_px-to-rem.scss +1 -1
  69. data/node_modules/govuk-frontend/govuk/vendor/polyfills/Date/now.js +21 -0
  70. data/node_modules/govuk-frontend/govuk/vendor/polyfills/Element/prototype/dataset.js +300 -0
  71. data/node_modules/govuk-frontend/govuk/vendor/polyfills/String/prototype/trim.js +21 -0
  72. data/node_modules/govuk-frontend/govuk-prototype-kit/init.js +7 -0
  73. data/node_modules/govuk-frontend/govuk-prototype-kit/init.scss +12 -0
  74. data/package-lock.json +12 -12
  75. data/package.json +1 -1
  76. metadata +17 -5
@@ -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;