govuk_publishing_components 32.1.0 → 33.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/component_guide/accessibility-test.js +0 -1
  3. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-core.js +175 -0
  4. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-ecommerce-tracker.js +4 -4
  5. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-event-tracker.js +5 -13
  6. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-link-tracker.js +80 -309
  7. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-page-views.js +2 -2
  8. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-specialist-link-tracker.js +140 -0
  9. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4/init-ga4.js +3 -0
  10. data/app/assets/javascripts/govuk_publishing_components/analytics-ga4.js +1 -0
  11. data/app/assets/javascripts/govuk_publishing_components/components/accordion.js +12 -1
  12. data/app/assets/javascripts/govuk_publishing_components/components/single-page-notification-button.js +24 -8
  13. data/app/assets/javascripts/govuk_publishing_components/components/step-by-step-nav.js +22 -1
  14. data/app/assets/javascripts/govuk_publishing_components/vendor/lux/lux-reporter.js +140 -191
  15. data/app/assets/stylesheets/govuk_publishing_components/components/_big-number.scss +2 -5
  16. data/app/assets/stylesheets/govuk_publishing_components/components/_image-card.scss +1 -5
  17. data/app/assets/stylesheets/govuk_publishing_components/components/_input.scss +3 -5
  18. data/app/assets/stylesheets/govuk_publishing_components/components/_layout-super-navigation-header.scss +10 -30
  19. data/app/assets/stylesheets/govuk_publishing_components/components/_search.scss +0 -7
  20. data/app/views/govuk_publishing_components/components/_accordion.html.erb +14 -1
  21. data/app/views/govuk_publishing_components/components/_error_summary.html.erb +27 -26
  22. data/app/views/govuk_publishing_components/components/_layout_super_navigation_header.html.erb +2 -2
  23. data/app/views/govuk_publishing_components/components/_phase_banner.html.erb +1 -1
  24. data/app/views/govuk_publishing_components/components/_share_links.html.erb +11 -13
  25. data/app/views/govuk_publishing_components/components/_single_page_notification_button.html.erb +1 -1
  26. data/app/views/govuk_publishing_components/components/_step_by_step_nav.html.erb +4 -1
  27. data/app/views/govuk_publishing_components/components/docs/accordion.yml +15 -3
  28. data/app/views/govuk_publishing_components/components/docs/button.yml +10 -0
  29. data/app/views/govuk_publishing_components/components/docs/share_links.yml +59 -30
  30. data/app/views/govuk_publishing_components/components/docs/single_page_notification_button.yml +10 -1
  31. data/app/views/govuk_publishing_components/components/docs/step_by_step_nav.yml +34 -0
  32. data/app/views/govuk_publishing_components/components/feedback/_problem_form.html.erb +1 -1
  33. data/app/views/govuk_publishing_components/components/feedback/_survey_signup_form.html.erb +1 -1
  34. data/app/views/govuk_publishing_components/components/feedback/_yes_no_banner.html.erb +3 -3
  35. data/lib/govuk_publishing_components/presenters/button_helper.rb +9 -2
  36. data/lib/govuk_publishing_components/presenters/single_page_notification_button_helper.rb +25 -1
  37. data/lib/govuk_publishing_components/version.rb +1 -1
  38. data/node_modules/axe-core/axe.js +4559 -4673
  39. data/node_modules/axe-core/axe.min.js +2 -2
  40. data/node_modules/axe-core/package.json +2 -2
  41. data/node_modules/axe-core/sri-history.json +4 -0
  42. data/node_modules/govuk-frontend/README.md +1 -2
  43. data/node_modules/govuk-frontend/govuk/all.js +1398 -273
  44. data/node_modules/govuk-frontend/govuk/common/closest-attribute-value.js +70 -0
  45. data/node_modules/govuk-frontend/govuk/common/index.js +172 -0
  46. data/node_modules/govuk-frontend/govuk/common/normalise-dataset.js +373 -0
  47. data/node_modules/govuk-frontend/govuk/common.js +138 -3
  48. data/node_modules/govuk-frontend/govuk/components/accordion/accordion.js +753 -25
  49. data/node_modules/govuk-frontend/govuk/components/accordion/fixtures.json +54 -22
  50. data/node_modules/govuk-frontend/govuk/components/accordion/macro-options.json +36 -0
  51. data/node_modules/govuk-frontend/govuk/components/accordion/template.njk +7 -1
  52. data/node_modules/govuk-frontend/govuk/components/back-link/fixtures.json +12 -12
  53. data/node_modules/govuk-frontend/govuk/components/breadcrumbs/fixtures.json +22 -22
  54. data/node_modules/govuk-frontend/govuk/components/button/_index.scss +23 -5
  55. data/node_modules/govuk-frontend/govuk/components/button/button.js +365 -107
  56. data/node_modules/govuk-frontend/govuk/components/button/fixtures.json +85 -66
  57. data/node_modules/govuk-frontend/govuk/components/button/template.njk +1 -1
  58. data/node_modules/govuk-frontend/govuk/components/character-count/_index.scss +9 -0
  59. data/node_modules/govuk-frontend/govuk/components/character-count/character-count.js +1033 -121
  60. data/node_modules/govuk-frontend/govuk/components/character-count/fixtures.json +112 -36
  61. data/node_modules/govuk-frontend/govuk/components/character-count/macro-options.json +42 -0
  62. data/node_modules/govuk-frontend/govuk/components/character-count/template.njk +27 -3
  63. data/node_modules/govuk-frontend/govuk/components/checkboxes/checkboxes.js +30 -2
  64. data/node_modules/govuk-frontend/govuk/components/checkboxes/fixtures.json +96 -93
  65. data/node_modules/govuk-frontend/govuk/components/cookie-banner/fixtures.json +46 -46
  66. data/node_modules/govuk-frontend/govuk/components/date-input/fixtures.json +50 -50
  67. data/node_modules/govuk-frontend/govuk/components/details/details.js +43 -13
  68. data/node_modules/govuk-frontend/govuk/components/details/fixtures.json +20 -20
  69. data/node_modules/govuk-frontend/govuk/components/error-message/fixtures.json +20 -20
  70. data/node_modules/govuk-frontend/govuk/components/error-summary/error-summary.js +268 -6
  71. data/node_modules/govuk-frontend/govuk/components/error-summary/fixtures.json +44 -35
  72. data/node_modules/govuk-frontend/govuk/components/error-summary/template.njk +25 -21
  73. data/node_modules/govuk-frontend/govuk/components/fieldset/fixtures.json +51 -39
  74. data/node_modules/govuk-frontend/govuk/components/file-upload/fixtures.json +26 -26
  75. data/node_modules/govuk-frontend/govuk/components/footer/_index.scss +1 -1
  76. data/node_modules/govuk-frontend/govuk/components/footer/fixtures.json +46 -46
  77. data/node_modules/govuk-frontend/govuk/components/footer/macro-options.json +2 -2
  78. data/node_modules/govuk-frontend/govuk/components/header/fixtures.json +93 -38
  79. data/node_modules/govuk-frontend/govuk/components/header/header.js +6 -0
  80. data/node_modules/govuk-frontend/govuk/components/header/macro-options.json +8 -2
  81. data/node_modules/govuk-frontend/govuk/components/header/template.njk +4 -2
  82. data/node_modules/govuk-frontend/govuk/components/hint/fixtures.json +12 -12
  83. data/node_modules/govuk-frontend/govuk/components/input/fixtures.json +80 -80
  84. data/node_modules/govuk-frontend/govuk/components/inset-text/fixtures.json +12 -12
  85. data/node_modules/govuk-frontend/govuk/components/label/fixtures.json +34 -34
  86. data/node_modules/govuk-frontend/govuk/components/notification-banner/fixtures.json +56 -46
  87. data/node_modules/govuk-frontend/govuk/components/notification-banner/notification-banner.js +252 -2
  88. data/node_modules/govuk-frontend/govuk/components/notification-banner/template.njk +1 -1
  89. data/node_modules/govuk-frontend/govuk/components/pagination/_index.scss +10 -7
  90. data/node_modules/govuk-frontend/govuk/components/pagination/fixtures.json +33 -26
  91. data/node_modules/govuk-frontend/govuk/components/panel/fixtures.json +18 -18
  92. data/node_modules/govuk-frontend/govuk/components/phase-banner/fixtures.json +14 -14
  93. data/node_modules/govuk-frontend/govuk/components/radios/fixtures.json +94 -91
  94. data/node_modules/govuk-frontend/govuk/components/radios/radios.js +30 -2
  95. data/node_modules/govuk-frontend/govuk/components/select/fixtures.json +32 -32
  96. data/node_modules/govuk-frontend/govuk/components/skip-link/fixtures.json +22 -20
  97. data/node_modules/govuk-frontend/govuk/components/skip-link/skip-link.js +10 -4
  98. data/node_modules/govuk-frontend/govuk/components/summary-list/fixtures.json +50 -50
  99. data/node_modules/govuk-frontend/govuk/components/table/_index.scss +1 -1
  100. data/node_modules/govuk-frontend/govuk/components/table/fixtures.json +40 -40
  101. data/node_modules/govuk-frontend/govuk/components/tabs/fixtures.json +29 -29
  102. data/node_modules/govuk-frontend/govuk/components/tabs/tabs.js +28 -0
  103. data/node_modules/govuk-frontend/govuk/components/tag/fixtures.json +28 -28
  104. data/node_modules/govuk-frontend/govuk/components/textarea/fixtures.json +34 -34
  105. data/node_modules/govuk-frontend/govuk/components/warning-text/fixtures.json +14 -14
  106. data/node_modules/govuk-frontend/govuk/core/_section-break.scss +1 -1
  107. data/node_modules/govuk-frontend/govuk/helpers/_colour.scss +2 -2
  108. data/node_modules/govuk-frontend/govuk/helpers/_links.scss +6 -6
  109. data/node_modules/govuk-frontend/govuk/i18n.js +390 -0
  110. data/node_modules/govuk-frontend/govuk/macros/i18n.njk +15 -0
  111. data/node_modules/govuk-frontend/govuk/settings/_all.scss +1 -0
  112. data/node_modules/govuk-frontend/govuk/settings/_colours-palette.scss +12 -0
  113. data/node_modules/govuk-frontend/govuk/settings/_compatibility.scss +26 -0
  114. data/node_modules/govuk-frontend/govuk/settings/_typography-font.scss +23 -0
  115. data/node_modules/govuk-frontend/govuk/settings/_typography-responsive.scss +12 -0
  116. data/node_modules/govuk-frontend/govuk/settings/_warnings.scss +53 -0
  117. data/node_modules/govuk-frontend/govuk/tools/_compatibility.scss +20 -6
  118. data/node_modules/govuk-frontend/govuk/vendor/polyfills/Date/now.js +21 -0
  119. data/node_modules/govuk-frontend/govuk/vendor/polyfills/Element/prototype/dataset.js +300 -0
  120. data/node_modules/govuk-frontend/govuk/vendor/polyfills/String/prototype/trim.js +21 -0
  121. data/node_modules/govuk-frontend/govuk-esm/all.mjs +50 -27
  122. data/node_modules/govuk-frontend/govuk-esm/common/closest-attribute-value.mjs +15 -0
  123. data/node_modules/govuk-frontend/govuk-esm/common/index.mjs +159 -0
  124. data/node_modules/govuk-frontend/govuk-esm/common/normalise-dataset.mjs +58 -0
  125. data/node_modules/govuk-frontend/govuk-esm/common.mjs +6 -28
  126. data/node_modules/govuk-frontend/govuk-esm/components/accordion/accordion.mjs +113 -43
  127. data/node_modules/govuk-frontend/govuk-esm/components/button/button.mjs +67 -30
  128. data/node_modules/govuk-frontend/govuk-esm/components/character-count/character-count.mjs +325 -123
  129. data/node_modules/govuk-frontend/govuk-esm/components/checkboxes/checkboxes.mjs +9 -3
  130. data/node_modules/govuk-frontend/govuk-esm/components/details/details.mjs +22 -8
  131. data/node_modules/govuk-frontend/govuk-esm/components/error-summary/error-summary.mjs +48 -6
  132. data/node_modules/govuk-frontend/govuk-esm/components/header/header.mjs +6 -0
  133. data/node_modules/govuk-frontend/govuk-esm/components/notification-banner/notification-banner.mjs +32 -2
  134. data/node_modules/govuk-frontend/govuk-esm/components/radios/radios.mjs +9 -3
  135. data/node_modules/govuk-frontend/govuk-esm/components/skip-link/skip-link.mjs +10 -4
  136. data/node_modules/govuk-frontend/govuk-esm/components/tabs/tabs.mjs +8 -2
  137. data/node_modules/govuk-frontend/govuk-esm/i18n.mjs +380 -0
  138. data/node_modules/govuk-frontend/govuk-esm/vendor/polyfills/Date/now.mjs +13 -0
  139. data/node_modules/govuk-frontend/govuk-esm/vendor/polyfills/Element/prototype/dataset.mjs +68 -0
  140. data/node_modules/govuk-frontend/govuk-esm/vendor/polyfills/String/prototype/trim.mjs +13 -0
  141. data/node_modules/govuk-frontend/govuk-prototype-kit/init.js +7 -0
  142. data/node_modules/govuk-frontend/govuk-prototype-kit/init.scss +12 -0
  143. data/node_modules/govuk-frontend/govuk-prototype-kit.config.json +138 -7
  144. data/node_modules/govuk-frontend/package.json +1 -1
  145. metadata +22 -3
@@ -4,6 +4,20 @@
4
4
  (global.GOVUKFrontend = global.GOVUKFrontend || {}, global.GOVUKFrontend.CharacterCount = factory());
5
5
  }(this, (function () { 'use strict';
6
6
 
7
+ (function(undefined) {
8
+
9
+ // Detection from https://github.com/Financial-Times/polyfill-library/blob/v3.111.0/polyfills/Date/now/detect.js
10
+ var detect = ('Date' in self && 'now' in self.Date && 'getTime' in self.Date.prototype);
11
+
12
+ if (detect) return
13
+
14
+ // Polyfill from https://polyfill.io/v3/polyfill.js?version=3.111.0&features=Date.now&flags=always
15
+ Date.now = function () {
16
+ return new Date().getTime();
17
+ };
18
+
19
+ }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
20
+
7
21
  (function(undefined) {
8
22
 
9
23
  // Detection from https://github.com/Financial-Times/polyfill-service/blob/master/packages/polyfill-library/polyfills/Object/defineProperty/detect.js
@@ -1014,7 +1028,801 @@ if (detect) return
1014
1028
 
1015
1029
  }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
1016
1030
 
1017
- function CharacterCount ($module) {
1031
+ /**
1032
+ * Common helpers which do not require polyfill.
1033
+ *
1034
+ * IMPORTANT: If a helper require a polyfill, please isolate it in its own module
1035
+ * so that the polyfill can be properly tree-shaken and does not burden
1036
+ * the components that do not need that helper
1037
+ *
1038
+ * @module common/index
1039
+ */
1040
+
1041
+ /**
1042
+ * Config flattening function
1043
+ *
1044
+ * Takes any number of objects, flattens them into namespaced key-value pairs,
1045
+ * (e.g. {'i18n.showSection': 'Show section'}) and combines them together, with
1046
+ * greatest priority on the LAST item passed in.
1047
+ *
1048
+ * @returns {object} A flattened object of key-value pairs.
1049
+ */
1050
+ function mergeConfigs (/* configObject1, configObject2, ...configObjects */) {
1051
+ /**
1052
+ * Function to take nested objects and flatten them to a dot-separated keyed
1053
+ * object. Doing this means we don't need to do any deep/recursive merging of
1054
+ * each of our objects, nor transform our dataset from a flat list into a
1055
+ * nested object.
1056
+ *
1057
+ * @param {object} configObject - Deeply nested object
1058
+ * @returns {object} Flattened object with dot-separated keys
1059
+ */
1060
+ var flattenObject = function (configObject) {
1061
+ // Prepare an empty return object
1062
+ var flattenedObject = {};
1063
+
1064
+ // Our flattening function, this is called recursively for each level of
1065
+ // depth in the object. At each level we prepend the previous level names to
1066
+ // the key using `prefix`.
1067
+ var flattenLoop = function (obj, prefix) {
1068
+ // Loop through keys...
1069
+ for (var key in obj) {
1070
+ // Check to see if this is a prototypical key/value,
1071
+ // if it is, skip it.
1072
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) {
1073
+ continue
1074
+ }
1075
+ var value = obj[key];
1076
+ var prefixedKey = prefix ? prefix + '.' + key : key;
1077
+ if (typeof value === 'object') {
1078
+ // If the value is a nested object, recurse over that too
1079
+ flattenLoop(value, prefixedKey);
1080
+ } else {
1081
+ // Otherwise, add this value to our return object
1082
+ flattenedObject[prefixedKey] = value;
1083
+ }
1084
+ }
1085
+ };
1086
+
1087
+ // Kick off the recursive loop
1088
+ flattenLoop(configObject);
1089
+ return flattenedObject
1090
+ };
1091
+
1092
+ // Start with an empty object as our base
1093
+ var formattedConfigObject = {};
1094
+
1095
+ // Loop through each of the remaining passed objects and push their keys
1096
+ // one-by-one into configObject. Any duplicate keys will override the existing
1097
+ // key with the new value.
1098
+ for (var i = 0; i < arguments.length; i++) {
1099
+ var obj = flattenObject(arguments[i]);
1100
+ for (var key in obj) {
1101
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
1102
+ formattedConfigObject[key] = obj[key];
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ return formattedConfigObject
1108
+ }
1109
+
1110
+ /**
1111
+ * Extracts keys starting with a particular namespace from a flattened config
1112
+ * object, removing the namespace in the process.
1113
+ *
1114
+ * @param {object} configObject - The object to extract key-value pairs from.
1115
+ * @param {string} namespace - The namespace to filter keys with.
1116
+ * @returns {object} Flattened object with dot-separated key namespace removed
1117
+ */
1118
+ function extractConfigByNamespace (configObject, namespace) {
1119
+ // Check we have what we need
1120
+ if (!configObject || typeof configObject !== 'object') {
1121
+ throw new Error('Provide a `configObject` of type "object".')
1122
+ }
1123
+ if (!namespace || typeof namespace !== 'string') {
1124
+ throw new Error('Provide a `namespace` of type "string" to filter the `configObject` by.')
1125
+ }
1126
+ var newObject = {};
1127
+ for (var key in configObject) {
1128
+ // Split the key into parts, using . as our namespace separator
1129
+ var keyParts = key.split('.');
1130
+ // Check if the first namespace matches the configured namespace
1131
+ if (Object.prototype.hasOwnProperty.call(configObject, key) && keyParts[0] === namespace) {
1132
+ // Remove the first item (the namespace) from the parts array,
1133
+ // but only if there is more than one part (we don't want blank keys!)
1134
+ if (keyParts.length > 1) {
1135
+ keyParts.shift();
1136
+ }
1137
+ // Join the remaining parts back together
1138
+ var newKey = keyParts.join('.');
1139
+ // Add them to our new object
1140
+ newObject[newKey] = configObject[key];
1141
+ }
1142
+ }
1143
+ return newObject
1144
+ }
1145
+
1146
+ /**
1147
+ * @callback nodeListIterator
1148
+ * @param {Element} value - The current node being iterated on
1149
+ * @param {number} index - The current index in the iteration
1150
+ * @param {NodeListOf<Element>} nodes - NodeList from querySelectorAll()
1151
+ * @returns {undefined}
1152
+ */
1153
+
1154
+ /**
1155
+ * Internal support for selecting messages to render, with placeholder
1156
+ * interpolation and locale-aware number formatting and pluralisation
1157
+ *
1158
+ * @class
1159
+ * @private
1160
+ * @param {TranslationsFlattened} translations - Key-value pairs of the translation strings to use.
1161
+ * @param {object} [config] - Configuration options for the function.
1162
+ * @param {string} config.locale - An overriding locale for the PluralRules functionality.
1163
+ */
1164
+ function I18n (translations, config) {
1165
+ // Make list of translations available throughout function
1166
+ this.translations = translations || {};
1167
+
1168
+ // The locale to use for PluralRules and NumberFormat
1169
+ this.locale = (config && config.locale) || document.documentElement.lang || 'en';
1170
+ }
1171
+
1172
+ /**
1173
+ * The most used function - takes the key for a given piece of UI text and
1174
+ * returns the appropriate string.
1175
+ *
1176
+ * @param {string} lookupKey - The lookup key of the string to use.
1177
+ * @param {object} options - Any options passed with the translation string, e.g: for string interpolation.
1178
+ * @returns {string} The appropriate translation string.
1179
+ */
1180
+ I18n.prototype.t = function (lookupKey, options) {
1181
+ if (!lookupKey) {
1182
+ // Print a console error if no lookup key has been provided
1183
+ throw new Error('i18n: lookup key missing')
1184
+ }
1185
+
1186
+ // If the `count` option is set, determine which plural suffix is needed and
1187
+ // change the lookupKey to match. We check to see if it's undefined instead of
1188
+ // falsy, as this could legitimately be 0.
1189
+ if (options && typeof options.count !== 'undefined') {
1190
+ // Get the plural suffix
1191
+ lookupKey = lookupKey + '.' + this.getPluralSuffix(lookupKey, options.count);
1192
+ }
1193
+
1194
+ if (lookupKey in this.translations) {
1195
+ // Fetch the translation string for that lookup key
1196
+ var translationString = this.translations[lookupKey];
1197
+
1198
+ // Check for ${} placeholders in the translation string
1199
+ if (translationString.match(/%{(.\S+)}/)) {
1200
+ if (!options) {
1201
+ throw new Error('i18n: cannot replace placeholders in string if no option data provided')
1202
+ }
1203
+
1204
+ return this.replacePlaceholders(translationString, options)
1205
+ } else {
1206
+ return translationString
1207
+ }
1208
+ } else {
1209
+ // If the key wasn't found in our translations object,
1210
+ // return the lookup key itself as the fallback
1211
+ return lookupKey
1212
+ }
1213
+ };
1214
+
1215
+ /**
1216
+ * Takes a translation string with placeholders, and replaces the placeholders
1217
+ * with the provided data
1218
+ *
1219
+ * @param {string} translationString - The translation string
1220
+ * @param {object} options - Any options passed with the translation string, e.g: for string interpolation.
1221
+ * @returns {string} The translation string to output, with ${} placeholders replaced
1222
+ */
1223
+ I18n.prototype.replacePlaceholders = function (translationString, options) {
1224
+ var formatter;
1225
+
1226
+ if (this.hasIntlNumberFormatSupport()) {
1227
+ formatter = new Intl.NumberFormat(this.locale);
1228
+ }
1229
+
1230
+ return translationString.replace(/%{(.\S+)}/g, function (placeholderWithBraces, placeholderKey) {
1231
+ if (Object.prototype.hasOwnProperty.call(options, placeholderKey)) {
1232
+ var placeholderValue = options[placeholderKey];
1233
+
1234
+ // If a user has passed `false` as the value for the placeholder
1235
+ // treat it as though the value should not be displayed
1236
+ if (placeholderValue === false) {
1237
+ return ''
1238
+ }
1239
+
1240
+ // If the placeholder's value is a number, localise the number formatting
1241
+ if (typeof placeholderValue === 'number' && formatter) {
1242
+ return formatter.format(placeholderValue)
1243
+ }
1244
+
1245
+ return placeholderValue
1246
+ } else {
1247
+ throw new Error('i18n: no data found to replace ' + placeholderWithBraces + ' placeholder in string')
1248
+ }
1249
+ })
1250
+ };
1251
+
1252
+ /**
1253
+ * Check to see if the browser supports Intl and Intl.PluralRules.
1254
+ *
1255
+ * It requires all conditions to be met in order to be supported:
1256
+ * - The browser supports the Intl class (true in IE11)
1257
+ * - The implementation of Intl supports PluralRules (NOT true in IE11)
1258
+ * - The browser/OS has plural rules for the current locale (browser dependent)
1259
+ *
1260
+ * @returns {boolean} Returns true if all conditions are met. Returns false otherwise.
1261
+ */
1262
+ I18n.prototype.hasIntlPluralRulesSupport = function () {
1263
+ return Boolean(window.Intl && ('PluralRules' in window.Intl && Intl.PluralRules.supportedLocalesOf(this.locale).length))
1264
+ };
1265
+
1266
+ /**
1267
+ * Check to see if the browser supports Intl and Intl.NumberFormat.
1268
+ *
1269
+ * It requires all conditions to be met in order to be supported:
1270
+ * - The browser supports the Intl class (true in IE11)
1271
+ * - The implementation of Intl supports NumberFormat (also true in IE11)
1272
+ * - The browser/OS has number formatting rules for the current locale (browser dependent)
1273
+ *
1274
+ * @returns {boolean} Returns true if all conditions are met. Returns false otherwise.
1275
+ */
1276
+ I18n.prototype.hasIntlNumberFormatSupport = function () {
1277
+ return Boolean(window.Intl && ('NumberFormat' in window.Intl && Intl.NumberFormat.supportedLocalesOf(this.locale).length))
1278
+ };
1279
+
1280
+ /**
1281
+ * Get the appropriate suffix for the plural form.
1282
+ *
1283
+ * Uses Intl.PluralRules (or our own fallback implementation) to get the
1284
+ * 'preferred' form to use for the given count.
1285
+ *
1286
+ * Checks that a translation has been provided for that plural form – if it
1287
+ * hasn't, it'll fall back to the 'other' plural form (unless that doesn't exist
1288
+ * either, in which case an error will be thrown)
1289
+ *
1290
+ * @param {string} lookupKey - The lookup key of the string to use.
1291
+ * @param {number} count - Number used to determine which pluralisation to use.
1292
+ * @returns {PluralRule} The suffix associated with the correct pluralisation for this locale.
1293
+ */
1294
+ I18n.prototype.getPluralSuffix = function (lookupKey, count) {
1295
+ // Validate that the number is actually a number.
1296
+ //
1297
+ // Number(count) will turn anything that can't be converted to a Number type
1298
+ // into 'NaN'. isFinite filters out NaN, as it isn't a finite number.
1299
+ count = Number(count);
1300
+ if (!isFinite(count)) { return 'other' }
1301
+
1302
+ var preferredForm;
1303
+
1304
+ // Check to verify that all the requirements for Intl.PluralRules are met.
1305
+ // If so, we can use that instead of our custom implementation. Otherwise,
1306
+ // use the hardcoded fallback.
1307
+ if (this.hasIntlPluralRulesSupport()) {
1308
+ preferredForm = new Intl.PluralRules(this.locale).select(count);
1309
+ } else {
1310
+ preferredForm = this.selectPluralFormUsingFallbackRules(count);
1311
+ }
1312
+
1313
+ // Use the correct plural form if provided
1314
+ if (lookupKey + '.' + preferredForm in this.translations) {
1315
+ return preferredForm
1316
+ // Fall back to `other` if the plural form is missing, but log a warning
1317
+ // to the console
1318
+ } else if (lookupKey + '.other' in this.translations) {
1319
+ if (console && 'warn' in console) {
1320
+ console.warn('i18n: Missing plural form ".' + preferredForm + '" for "' +
1321
+ this.locale + '" locale. Falling back to ".other".');
1322
+ }
1323
+
1324
+ return 'other'
1325
+ // If the required `other` plural form is missing, all we can do is error
1326
+ } else {
1327
+ throw new Error(
1328
+ 'i18n: Plural form ".other" is required for "' + this.locale + '" locale'
1329
+ )
1330
+ }
1331
+ };
1332
+
1333
+ /**
1334
+ * Get the plural form using our fallback implementation
1335
+ *
1336
+ * This is split out into a separate function to make it easier to test the
1337
+ * fallback behaviour in an environment where Intl.PluralRules exists.
1338
+ *
1339
+ * @param {number} count - Number used to determine which pluralisation to use.
1340
+ * @returns {PluralRule} The pluralisation form for count in this locale.
1341
+ */
1342
+ I18n.prototype.selectPluralFormUsingFallbackRules = function (count) {
1343
+ // Currently our custom code can only handle positive integers, so let's
1344
+ // make sure our number is one of those.
1345
+ count = Math.abs(Math.floor(count));
1346
+
1347
+ var ruleset = this.getPluralRulesForLocale();
1348
+
1349
+ if (ruleset) {
1350
+ return I18n.pluralRules[ruleset](count)
1351
+ }
1352
+
1353
+ return 'other'
1354
+ };
1355
+
1356
+ /**
1357
+ * Work out which pluralisation rules to use for the current locale
1358
+ *
1359
+ * The locale may include a regional indicator (such as en-GB), but we don't
1360
+ * usually care about this part, as pluralisation rules are usually the same
1361
+ * regardless of region. There are exceptions, however, (e.g. Portuguese) so
1362
+ * this searches by both the full and shortened locale codes, just to be sure.
1363
+ *
1364
+ * @returns {PluralRuleName | undefined} The name of the pluralisation rule to use (a key for one
1365
+ * of the functions in this.pluralRules)
1366
+ */
1367
+ I18n.prototype.getPluralRulesForLocale = function () {
1368
+ var locale = this.locale;
1369
+ var localeShort = locale.split('-')[0];
1370
+
1371
+ // Look through the plural rules map to find which `pluralRule` is
1372
+ // appropriate for our current `locale`.
1373
+ for (var pluralRule in I18n.pluralRulesMap) {
1374
+ if (Object.prototype.hasOwnProperty.call(I18n.pluralRulesMap, pluralRule)) {
1375
+ var languages = I18n.pluralRulesMap[pluralRule];
1376
+ for (var i = 0; i < languages.length; i++) {
1377
+ if (languages[i] === locale || languages[i] === localeShort) {
1378
+ return pluralRule
1379
+ }
1380
+ }
1381
+ }
1382
+ }
1383
+ };
1384
+
1385
+ /**
1386
+ * Map of plural rules to languages where those rules apply.
1387
+ *
1388
+ * Note: These groups are named for the most dominant or recognisable language
1389
+ * that uses each system. The groupings do not imply that the languages are
1390
+ * related to one another. Many languages have evolved the same systems
1391
+ * independently of one another.
1392
+ *
1393
+ * Code to support more languages can be found in the i18n spike:
1394
+ * {@link https://github.com/alphagov/govuk-frontend/blob/spike-i18n-support/src/govuk/i18n.mjs}
1395
+ *
1396
+ * Languages currently supported:
1397
+ *
1398
+ * Arabic: Arabic (ar)
1399
+ * Chinese: Burmese (my), Chinese (zh), Indonesian (id), Japanese (ja),
1400
+ * Javanese (jv), Korean (ko), Malay (ms), Thai (th), Vietnamese (vi)
1401
+ * French: Armenian (hy), Bangla (bn), French (fr), Gujarati (gu), Hindi (hi),
1402
+ * Persian Farsi (fa), Punjabi (pa), Zulu (zu)
1403
+ * German: Afrikaans (af), Albanian (sq), Azerbaijani (az), Basque (eu),
1404
+ * Bulgarian (bg), Catalan (ca), Danish (da), Dutch (nl), English (en),
1405
+ * Estonian (et), Finnish (fi), Georgian (ka), German (de), Greek (el),
1406
+ * Hungarian (hu), Luxembourgish (lb), Norwegian (no), Somali (so),
1407
+ * Swahili (sw), Swedish (sv), Tamil (ta), Telugu (te), Turkish (tr),
1408
+ * Urdu (ur)
1409
+ * Irish: Irish Gaelic (ga)
1410
+ * Russian: Russian (ru), Ukrainian (uk)
1411
+ * Scottish: Scottish Gaelic (gd)
1412
+ * Spanish: European Portuguese (pt-PT), Italian (it), Spanish (es)
1413
+ * Welsh: Welsh (cy)
1414
+ *
1415
+ * @type {Object<PluralRuleName, string[]>}
1416
+ */
1417
+ I18n.pluralRulesMap = {
1418
+ arabic: ['ar'],
1419
+ chinese: ['my', 'zh', 'id', 'ja', 'jv', 'ko', 'ms', 'th', 'vi'],
1420
+ french: ['hy', 'bn', 'fr', 'gu', 'hi', 'fa', 'pa', 'zu'],
1421
+ german: [
1422
+ 'af', 'sq', 'az', 'eu', 'bg', 'ca', 'da', 'nl', 'en', 'et', 'fi', 'ka',
1423
+ 'de', 'el', 'hu', 'lb', 'no', 'so', 'sw', 'sv', 'ta', 'te', 'tr', 'ur'
1424
+ ],
1425
+ irish: ['ga'],
1426
+ russian: ['ru', 'uk'],
1427
+ scottish: ['gd'],
1428
+ spanish: ['pt-PT', 'it', 'es'],
1429
+ welsh: ['cy']
1430
+ };
1431
+
1432
+ /**
1433
+ * Different pluralisation rule sets
1434
+ *
1435
+ * Returns the appropriate suffix for the plural form associated with `n`.
1436
+ * Possible suffixes: 'zero', 'one', 'two', 'few', 'many', 'other' (the actual
1437
+ * meaning of each differs per locale). 'other' should always exist, even in
1438
+ * languages without plurals, such as Chinese.
1439
+ * {@link https://cldr.unicode.org/index/cldr-spec/plural-rules}
1440
+ *
1441
+ * The count must be a positive integer. Negative numbers and decimals aren't accounted for
1442
+ *
1443
+ * @type {Object<string, function(number): PluralRule>}
1444
+ */
1445
+ I18n.pluralRules = {
1446
+ arabic: function (n) {
1447
+ if (n === 0) { return 'zero' }
1448
+ if (n === 1) { return 'one' }
1449
+ if (n === 2) { return 'two' }
1450
+ if (n % 100 >= 3 && n % 100 <= 10) { return 'few' }
1451
+ if (n % 100 >= 11 && n % 100 <= 99) { return 'many' }
1452
+ return 'other'
1453
+ },
1454
+ chinese: function () {
1455
+ return 'other'
1456
+ },
1457
+ french: function (n) {
1458
+ return n === 0 || n === 1 ? 'one' : 'other'
1459
+ },
1460
+ german: function (n) {
1461
+ return n === 1 ? 'one' : 'other'
1462
+ },
1463
+ irish: function (n) {
1464
+ if (n === 1) { return 'one' }
1465
+ if (n === 2) { return 'two' }
1466
+ if (n >= 3 && n <= 6) { return 'few' }
1467
+ if (n >= 7 && n <= 10) { return 'many' }
1468
+ return 'other'
1469
+ },
1470
+ russian: function (n) {
1471
+ var lastTwo = n % 100;
1472
+ var last = lastTwo % 10;
1473
+ if (last === 1 && lastTwo !== 11) { return 'one' }
1474
+ if (last >= 2 && last <= 4 && !(lastTwo >= 12 && lastTwo <= 14)) { return 'few' }
1475
+ if (last === 0 || (last >= 5 && last <= 9) || (lastTwo >= 11 && lastTwo <= 14)) { return 'many' }
1476
+ // Note: The 'other' suffix is only used by decimal numbers in Russian.
1477
+ // We don't anticipate it being used, but it's here for consistency.
1478
+ return 'other'
1479
+ },
1480
+ scottish: function (n) {
1481
+ if (n === 1 || n === 11) { return 'one' }
1482
+ if (n === 2 || n === 12) { return 'two' }
1483
+ if ((n >= 3 && n <= 10) || (n >= 13 && n <= 19)) { return 'few' }
1484
+ return 'other'
1485
+ },
1486
+ spanish: function (n) {
1487
+ if (n === 1) { return 'one' }
1488
+ if (n % 1000000 === 0 && n !== 0) { return 'many' }
1489
+ return 'other'
1490
+ },
1491
+ welsh: function (n) {
1492
+ if (n === 0) { return 'zero' }
1493
+ if (n === 1) { return 'one' }
1494
+ if (n === 2) { return 'two' }
1495
+ if (n === 3) { return 'few' }
1496
+ if (n === 6) { return 'many' }
1497
+ return 'other'
1498
+ }
1499
+ };
1500
+
1501
+ /**
1502
+ * Supported languages for plural rules
1503
+ *
1504
+ * @typedef {'arabic' | 'chinese' | 'french' | 'german' | 'irish' | 'russian' | 'scottish' | 'spanish' | 'welsh'} PluralRuleName
1505
+ */
1506
+
1507
+ /**
1508
+ * Plural rule category mnemonic tags
1509
+ *
1510
+ * @typedef {'zero' | 'one' | 'two' | 'few' | 'many' | 'other'} PluralRule
1511
+ */
1512
+
1513
+ /**
1514
+ * Translated message by plural rule they correspond to.
1515
+ *
1516
+ * Allows to group pluralised messages under a single key when passing
1517
+ * translations to a component's constructor
1518
+ *
1519
+ * @typedef {object} TranslationPluralForms
1520
+ * @property {string} [other] - General plural form
1521
+ * @property {string} [zero] - Plural form used with 0
1522
+ * @property {string} [one] - Plural form used with 1
1523
+ * @property {string} [two] - Plural form used with 2
1524
+ * @property {string} [few] - Plural form used for a few
1525
+ * @property {string} [many] - Plural form used for many
1526
+ */
1527
+
1528
+ /**
1529
+ * Translated messages (flattened)
1530
+ *
1531
+ * @private
1532
+ * @typedef {Object<string, string> | {}} TranslationsFlattened
1533
+ */
1534
+
1535
+ (function(undefined) {
1536
+
1537
+ // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-library/13cf7c340974d128d557580b5e2dafcd1b1192d1/polyfills/Element/prototype/dataset/detect.js
1538
+ var detect = (function(){
1539
+ if (!document.documentElement.dataset) {
1540
+ return false;
1541
+ }
1542
+ var el = document.createElement('div');
1543
+ el.setAttribute("data-a-b", "c");
1544
+ return el.dataset && el.dataset.aB == "c";
1545
+ }());
1546
+
1547
+ if (detect) return
1548
+
1549
+ // Polyfill derived from https://raw.githubusercontent.com/Financial-Times/polyfill-library/13cf7c340974d128d557580b5e2dafcd1b1192d1/polyfills/Element/prototype/dataset/polyfill.js
1550
+ Object.defineProperty(Element.prototype, 'dataset', {
1551
+ get: function() {
1552
+ var element = this;
1553
+ var attributes = this.attributes;
1554
+ var map = {};
1555
+
1556
+ for (var i = 0; i < attributes.length; i++) {
1557
+ var attribute = attributes[i];
1558
+
1559
+ // This regex has been edited from the original polyfill, to add
1560
+ // support for period (.) separators in data-* attribute names. These
1561
+ // are allowed in the HTML spec, but were not covered by the original
1562
+ // polyfill's regex. We use periods in our i18n implementation.
1563
+ if (attribute && attribute.name && (/^data-\w[.\w-]*$/).test(attribute.name)) {
1564
+ var name = attribute.name;
1565
+ var value = attribute.value;
1566
+
1567
+ var propName = name.substr(5).replace(/-./g, function (prop) {
1568
+ return prop.charAt(1).toUpperCase();
1569
+ });
1570
+
1571
+ // If this browser supports __defineGetter__ and __defineSetter__,
1572
+ // continue using defineProperty. If not (like IE 8 and below), we use
1573
+ // a hacky fallback which at least gives an object in the right format
1574
+ if ('__defineGetter__' in Object.prototype && '__defineSetter__' in Object.prototype) {
1575
+ Object.defineProperty(map, propName, {
1576
+ enumerable: true,
1577
+ get: function() {
1578
+ return this.value;
1579
+ }.bind({value: value || ''}),
1580
+ set: function setter(name, value) {
1581
+ if (typeof value !== 'undefined') {
1582
+ this.setAttribute(name, value);
1583
+ } else {
1584
+ this.removeAttribute(name);
1585
+ }
1586
+ }.bind(element, name)
1587
+ });
1588
+ } else {
1589
+ map[propName] = value;
1590
+ }
1591
+
1592
+ }
1593
+ }
1594
+
1595
+ return map;
1596
+ }
1597
+ });
1598
+
1599
+ }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
1600
+
1601
+ (function(undefined) {
1602
+
1603
+ // Detection from https://github.com/mdn/content/blob/cf607d68522cd35ee7670782d3ee3a361eaef2e4/files/en-us/web/javascript/reference/global_objects/string/trim/index.md#polyfill
1604
+ var detect = ('trim' in String.prototype);
1605
+
1606
+ if (detect) return
1607
+
1608
+ // Polyfill from https://github.com/mdn/content/blob/cf607d68522cd35ee7670782d3ee3a361eaef2e4/files/en-us/web/javascript/reference/global_objects/string/trim/index.md#polyfill
1609
+ String.prototype.trim = function () {
1610
+ return this.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, '');
1611
+ };
1612
+
1613
+ }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
1614
+
1615
+ /**
1616
+ * Normalise string
1617
+ *
1618
+ * 'If it looks like a duck, and it quacks like a duck…' 🦆
1619
+ *
1620
+ * If the passed value looks like a boolean or a number, convert it to a boolean
1621
+ * or number.
1622
+ *
1623
+ * Designed to be used to convert config passed via data attributes (which are
1624
+ * always strings) into something sensible.
1625
+ *
1626
+ * @param {string} value - The value to normalise
1627
+ * @returns {string | boolean | number | undefined} Normalised data
1628
+ */
1629
+ function normaliseString (value) {
1630
+ if (typeof value !== 'string') {
1631
+ return value
1632
+ }
1633
+
1634
+ var trimmedValue = value.trim();
1635
+
1636
+ if (trimmedValue === 'true') {
1637
+ return true
1638
+ }
1639
+
1640
+ if (trimmedValue === 'false') {
1641
+ return false
1642
+ }
1643
+
1644
+ // Empty / whitespace-only strings are considered finite so we need to check
1645
+ // the length of the trimmed string as well
1646
+ if (trimmedValue.length > 0 && isFinite(trimmedValue)) {
1647
+ return Number(trimmedValue)
1648
+ }
1649
+
1650
+ return value
1651
+ }
1652
+
1653
+ /**
1654
+ * Normalise dataset
1655
+ *
1656
+ * Loop over an object and normalise each value using normaliseData function
1657
+ *
1658
+ * @param {DOMStringMap} dataset - HTML element dataset
1659
+ * @returns {Object<string, string | boolean | number | undefined>} Normalised dataset
1660
+ */
1661
+ function normaliseDataset (dataset) {
1662
+ var out = {};
1663
+
1664
+ for (var key in dataset) {
1665
+ out[key] = normaliseString(dataset[key]);
1666
+ }
1667
+
1668
+ return out
1669
+ }
1670
+
1671
+ (function(undefined) {
1672
+
1673
+ // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/detect.js
1674
+ var detect = (
1675
+ 'document' in this && "matches" in document.documentElement
1676
+ );
1677
+
1678
+ if (detect) return
1679
+
1680
+ // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/matches/polyfill.js
1681
+ Element.prototype.matches = Element.prototype.webkitMatchesSelector || Element.prototype.oMatchesSelector || Element.prototype.msMatchesSelector || Element.prototype.mozMatchesSelector || function matches(selector) {
1682
+ var element = this;
1683
+ var elements = (element.document || element.ownerDocument).querySelectorAll(selector);
1684
+ var index = 0;
1685
+
1686
+ while (elements[index] && elements[index] !== element) {
1687
+ ++index;
1688
+ }
1689
+
1690
+ return !!elements[index];
1691
+ };
1692
+
1693
+ }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
1694
+
1695
+ (function(undefined) {
1696
+
1697
+ // Detection from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/detect.js
1698
+ var detect = (
1699
+ 'document' in this && "closest" in document.documentElement
1700
+ );
1701
+
1702
+ if (detect) return
1703
+
1704
+ // Polyfill from https://raw.githubusercontent.com/Financial-Times/polyfill-service/1f3c09b402f65bf6e393f933a15ba63f1b86ef1f/packages/polyfill-library/polyfills/Element/prototype/closest/polyfill.js
1705
+ Element.prototype.closest = function closest(selector) {
1706
+ var node = this;
1707
+
1708
+ while (node) {
1709
+ if (node.matches(selector)) return node;
1710
+ else node = 'SVGElement' in window && node instanceof SVGElement ? node.parentNode : node.parentElement;
1711
+ }
1712
+
1713
+ return null;
1714
+ };
1715
+
1716
+ }).call('object' === typeof window && window || 'object' === typeof self && self || 'object' === typeof global && global || {});
1717
+
1718
+ /**
1719
+ * Returns the value of the given attribute closest to the given element (including itself)
1720
+ *
1721
+ * @param {HTMLElement} $element - The element to start walking the DOM tree up
1722
+ * @param {string} attributeName - The name of the attribute
1723
+ * @returns {string | undefined} Attribute value
1724
+ */
1725
+ function closestAttributeValue ($element, attributeName) {
1726
+ var closestElementWithAttribute = $element.closest('[' + attributeName + ']');
1727
+ if (closestElementWithAttribute) {
1728
+ return closestElementWithAttribute.getAttribute(attributeName)
1729
+ }
1730
+ }
1731
+
1732
+ /**
1733
+ * @constant
1734
+ * @type {CharacterCountTranslations}
1735
+ * @see Default value for {@link CharacterCountConfig.i18n}
1736
+ * @default
1737
+ */
1738
+ var CHARACTER_COUNT_TRANSLATIONS = {
1739
+ // Characters
1740
+ charactersUnderLimit: {
1741
+ one: 'You have %{count} character remaining',
1742
+ other: 'You have %{count} characters remaining'
1743
+ },
1744
+ charactersAtLimit: 'You have 0 characters remaining',
1745
+ charactersOverLimit: {
1746
+ one: 'You have %{count} character too many',
1747
+ other: 'You have %{count} characters too many'
1748
+ },
1749
+ // Words
1750
+ wordsUnderLimit: {
1751
+ one: 'You have %{count} word remaining',
1752
+ other: 'You have %{count} words remaining'
1753
+ },
1754
+ wordsAtLimit: 'You have 0 words remaining',
1755
+ wordsOverLimit: {
1756
+ one: 'You have %{count} word too many',
1757
+ other: 'You have %{count} words too many'
1758
+ },
1759
+ textareaDescription: {
1760
+ other: ''
1761
+ }
1762
+ };
1763
+
1764
+ /**
1765
+ * JavaScript enhancements for the CharacterCount component
1766
+ *
1767
+ * Tracks the number of characters or words in the `.govuk-js-character-count`
1768
+ * `<textarea>` inside the element. Displays a message with the remaining number
1769
+ * of characters/words available, or the number of characters/words in excess.
1770
+ *
1771
+ * You can configure the message to only appear after a certain percentage
1772
+ * of the available characters/words has been entered.
1773
+ *
1774
+ * @class
1775
+ * @param {HTMLElement} $module - The element this component controls
1776
+ * @param {CharacterCountConfig} [config] - Character count config
1777
+ */
1778
+ function CharacterCount ($module, config) {
1779
+ if (!$module) {
1780
+ return this
1781
+ }
1782
+
1783
+ var defaultConfig = {
1784
+ threshold: 0,
1785
+ i18n: CHARACTER_COUNT_TRANSLATIONS
1786
+ };
1787
+
1788
+ // Read config set using dataset ('data-' values)
1789
+ var datasetConfig = normaliseDataset($module.dataset);
1790
+
1791
+ // To ensure data-attributes take complete precedence, even if they change the
1792
+ // type of count, we need to reset the `maxlength` and `maxwords` from the
1793
+ // JavaScript config.
1794
+ //
1795
+ // We can't mutate `config`, though, as it may be shared across multiple
1796
+ // components inside `initAll`.
1797
+ var configOverrides = {};
1798
+ if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
1799
+ configOverrides = {
1800
+ maxlength: false,
1801
+ maxwords: false
1802
+ };
1803
+ }
1804
+
1805
+ this.config = mergeConfigs(
1806
+ defaultConfig,
1807
+ config || {},
1808
+ configOverrides,
1809
+ datasetConfig
1810
+ );
1811
+
1812
+ this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'), {
1813
+ // Read the fallback if necessary rather than have it set in the defaults
1814
+ locale: closestAttributeValue($module, 'lang')
1815
+ });
1816
+
1817
+ // Determine the limit attribute (characters or words)
1818
+ if (this.config.maxwords) {
1819
+ this.maxLength = this.config.maxwords;
1820
+ } else if (this.config.maxlength) {
1821
+ this.maxLength = this.config.maxlength;
1822
+ } else {
1823
+ return
1824
+ }
1825
+
1018
1826
  this.$module = $module;
1019
1827
  this.$textarea = $module.querySelector('.govuk-js-character-count');
1020
1828
  this.$visibleCountMessage = null;
@@ -1022,26 +1830,28 @@ function CharacterCount ($module) {
1022
1830
  this.lastInputTimestamp = null;
1023
1831
  }
1024
1832
 
1025
- CharacterCount.prototype.defaults = {
1026
- characterCountAttribute: 'data-maxlength',
1027
- wordCountAttribute: 'data-maxwords'
1028
- };
1029
-
1030
- // Initialize component
1833
+ /**
1834
+ * Initialise component
1835
+ */
1031
1836
  CharacterCount.prototype.init = function () {
1032
1837
  // Check that required elements are present
1033
1838
  if (!this.$textarea) {
1034
1839
  return
1035
1840
  }
1036
1841
 
1037
- // Check for module
1038
- var $module = this.$module;
1039
1842
  var $textarea = this.$textarea;
1040
- var $fallbackLimitMessage = document.getElementById($textarea.id + '-info');
1843
+ var $textareaDescription = document.getElementById($textarea.id + '-info');
1041
1844
 
1042
- // Move the fallback count message to be immediately after the textarea
1845
+ // Inject a decription for the textarea if none is present already
1846
+ // for when the component was rendered with no maxlength, maxwords
1847
+ // nor custom textareaDescriptionText
1848
+ if ($textareaDescription.innerText.match(/^\s*$/)) {
1849
+ $textareaDescription.innerText = this.i18n.t('textareaDescription', { count: this.maxLength });
1850
+ }
1851
+
1852
+ // Move the textarea description to be immediately after the textarea
1043
1853
  // Kept for backwards compatibility
1044
- $textarea.insertAdjacentElement('afterend', $fallbackLimitMessage);
1854
+ $textarea.insertAdjacentElement('afterend', $textareaDescription);
1045
1855
 
1046
1856
  // Create the *screen reader* specific live-updating counter
1047
1857
  // This doesn't need any styling classes, as it is never visible
@@ -1049,36 +1859,20 @@ CharacterCount.prototype.init = function () {
1049
1859
  $screenReaderCountMessage.className = 'govuk-character-count__sr-status govuk-visually-hidden';
1050
1860
  $screenReaderCountMessage.setAttribute('aria-live', 'polite');
1051
1861
  this.$screenReaderCountMessage = $screenReaderCountMessage;
1052
- $fallbackLimitMessage.insertAdjacentElement('afterend', $screenReaderCountMessage);
1862
+ $textareaDescription.insertAdjacentElement('afterend', $screenReaderCountMessage);
1053
1863
 
1054
1864
  // Create our live-updating counter element, copying the classes from the
1055
- // fallback element for backwards compatibility as these may have been configured
1865
+ // textarea description for backwards compatibility as these may have been
1866
+ // configured
1056
1867
  var $visibleCountMessage = document.createElement('div');
1057
- $visibleCountMessage.className = $fallbackLimitMessage.className;
1868
+ $visibleCountMessage.className = $textareaDescription.className;
1058
1869
  $visibleCountMessage.classList.add('govuk-character-count__status');
1059
1870
  $visibleCountMessage.setAttribute('aria-hidden', 'true');
1060
1871
  this.$visibleCountMessage = $visibleCountMessage;
1061
- $fallbackLimitMessage.insertAdjacentElement('afterend', $visibleCountMessage);
1062
-
1063
- // Hide the fallback limit message
1064
- $fallbackLimitMessage.classList.add('govuk-visually-hidden');
1065
-
1066
- // Read options set using dataset ('data-' values)
1067
- this.options = this.getDataset($module);
1872
+ $textareaDescription.insertAdjacentElement('afterend', $visibleCountMessage);
1068
1873
 
1069
- // Determine the limit attribute (characters or words)
1070
- var countAttribute = this.defaults.characterCountAttribute;
1071
- if (this.options.maxwords) {
1072
- countAttribute = this.defaults.wordCountAttribute;
1073
- }
1074
-
1075
- // Save the element limit
1076
- this.maxLength = $module.getAttribute(countAttribute);
1077
-
1078
- // Check for limit
1079
- if (!this.maxLength) {
1080
- return
1081
- }
1874
+ // Hide the textarea description
1875
+ $textareaDescription.classList.add('govuk-visually-hidden');
1082
1876
 
1083
1877
  // Remove hard limit if set
1084
1878
  $textarea.removeAttribute('maxlength');
@@ -1086,9 +1880,9 @@ CharacterCount.prototype.init = function () {
1086
1880
  this.bindChangeEvents();
1087
1881
 
1088
1882
  // When the page is restored after navigating 'back' in some browsers the
1089
- // state of the character count is not restored until *after* the DOMContentLoaded
1090
- // event is fired, so we need to manually update it after the pageshow event
1091
- // in browsers that support it.
1883
+ // state of the character count is not restored until *after* the
1884
+ // DOMContentLoaded event is fired, so we need to manually update it after the
1885
+ // pageshow event in browsers that support it.
1092
1886
  if ('onpageshow' in window) {
1093
1887
  window.addEventListener('pageshow', this.updateCountMessage.bind(this));
1094
1888
  } else {
@@ -1097,35 +1891,12 @@ CharacterCount.prototype.init = function () {
1097
1891
  this.updateCountMessage();
1098
1892
  };
1099
1893
 
1100
- // Read data attributes
1101
- CharacterCount.prototype.getDataset = function (element) {
1102
- var dataset = {};
1103
- var attributes = element.attributes;
1104
- if (attributes) {
1105
- for (var i = 0; i < attributes.length; i++) {
1106
- var attribute = attributes[i];
1107
- var match = attribute.name.match(/^data-(.+)/);
1108
- if (match) {
1109
- dataset[match[1]] = attribute.value;
1110
- }
1111
- }
1112
- }
1113
- return dataset
1114
- };
1115
-
1116
- // Counts characters or words in text
1117
- CharacterCount.prototype.count = function (text) {
1118
- var length;
1119
- if (this.options.maxwords) {
1120
- var tokens = text.match(/\S+/g) || []; // Matches consecutive non-whitespace chars
1121
- length = tokens.length;
1122
- } else {
1123
- length = text.length;
1124
- }
1125
- return length
1126
- };
1127
-
1128
- // Bind input propertychange to the elements and update based on the change
1894
+ /**
1895
+ * Bind change events
1896
+ *
1897
+ * Set up event listeners on the $textarea so that the count messages update
1898
+ * when the user types.
1899
+ */
1129
1900
  CharacterCount.prototype.bindChangeEvents = function () {
1130
1901
  var $textarea = this.$textarea;
1131
1902
  $textarea.addEventListener('keyup', this.handleKeyUp.bind(this));
@@ -1135,10 +1906,52 @@ CharacterCount.prototype.bindChangeEvents = function () {
1135
1906
  $textarea.addEventListener('blur', this.handleBlur.bind(this));
1136
1907
  };
1137
1908
 
1138
- // Speech recognition software such as Dragon NaturallySpeaking will modify the
1139
- // fields by directly changing its `value`. These changes don't trigger events
1140
- // in JavaScript, so we need to poll to handle when and if they occur.
1141
- CharacterCount.prototype.checkIfValueChanged = function () {
1909
+ /**
1910
+ * Handle key up event
1911
+ *
1912
+ * Update the visible character counter and keep track of when the last update
1913
+ * happened for each keypress
1914
+ */
1915
+ CharacterCount.prototype.handleKeyUp = function () {
1916
+ this.updateVisibleCountMessage();
1917
+ this.lastInputTimestamp = Date.now();
1918
+ };
1919
+
1920
+ /**
1921
+ * Handle focus event
1922
+ *
1923
+ * Speech recognition software such as Dragon NaturallySpeaking will modify the
1924
+ * fields by directly changing its `value`. These changes don't trigger events
1925
+ * in JavaScript, so we need to poll to handle when and if they occur.
1926
+ *
1927
+ * Once the keyup event hasn't been detected for at least 1000 ms (1s), check if
1928
+ * the textarea value has changed and update the count message if it has.
1929
+ *
1930
+ * This is so that the update triggered by the manual comparison doesn't
1931
+ * conflict with debounced KeyboardEvent updates.
1932
+ */
1933
+ CharacterCount.prototype.handleFocus = function () {
1934
+ this.valueChecker = setInterval(function () {
1935
+ if (!this.lastInputTimestamp || (Date.now() - 500) >= this.lastInputTimestamp) {
1936
+ this.updateIfValueChanged();
1937
+ }
1938
+ }.bind(this), 1000);
1939
+ };
1940
+
1941
+ /**
1942
+ * Handle blur event
1943
+ *
1944
+ * Stop checking the textarea value once the textarea no longer has focus
1945
+ */
1946
+ CharacterCount.prototype.handleBlur = function () {
1947
+ // Cancel value checking on blur
1948
+ clearInterval(this.valueChecker);
1949
+ };
1950
+
1951
+ /**
1952
+ * Update count message if textarea value has changed
1953
+ */
1954
+ CharacterCount.prototype.updateIfValueChanged = function () {
1142
1955
  if (!this.$textarea.oldValue) this.$textarea.oldValue = '';
1143
1956
  if (this.$textarea.value !== this.$textarea.oldValue) {
1144
1957
  this.$textarea.oldValue = this.$textarea.value;
@@ -1146,14 +1959,20 @@ CharacterCount.prototype.checkIfValueChanged = function () {
1146
1959
  }
1147
1960
  };
1148
1961
 
1149
- // Helper function to update both the visible and screen reader-specific
1150
- // counters simultaneously (e.g. on init)
1962
+ /**
1963
+ * Update count message
1964
+ *
1965
+ * Helper function to update both the visible and screen reader-specific
1966
+ * counters simultaneously (e.g. on init)
1967
+ */
1151
1968
  CharacterCount.prototype.updateCountMessage = function () {
1152
1969
  this.updateVisibleCountMessage();
1153
1970
  this.updateScreenReaderCountMessage();
1154
1971
  };
1155
1972
 
1156
- // Update visible counter
1973
+ /**
1974
+ * Update visible count message
1975
+ */
1157
1976
  CharacterCount.prototype.updateVisibleCountMessage = function () {
1158
1977
  var $textarea = this.$textarea;
1159
1978
  var $visibleCountMessage = this.$visibleCountMessage;
@@ -1179,10 +1998,12 @@ CharacterCount.prototype.updateVisibleCountMessage = function () {
1179
1998
  }
1180
1999
 
1181
2000
  // Update message
1182
- $visibleCountMessage.innerHTML = this.formattedUpdateMessage();
2001
+ $visibleCountMessage.innerText = this.getCountMessage();
1183
2002
  };
1184
2003
 
1185
- // Update screen reader-specific counter
2004
+ /**
2005
+ * Update screen reader count message
2006
+ */
1186
2007
  CharacterCount.prototype.updateScreenReaderCountMessage = function () {
1187
2008
  var $screenReaderCountMessage = this.$screenReaderCountMessage;
1188
2009
 
@@ -1195,70 +2016,161 @@ CharacterCount.prototype.updateScreenReaderCountMessage = function () {
1195
2016
  }
1196
2017
 
1197
2018
  // Update message
1198
- $screenReaderCountMessage.innerHTML = this.formattedUpdateMessage();
2019
+ $screenReaderCountMessage.innerText = this.getCountMessage();
1199
2020
  };
1200
2021
 
1201
- // Format update message
1202
- CharacterCount.prototype.formattedUpdateMessage = function () {
1203
- var $textarea = this.$textarea;
1204
- var options = this.options;
1205
- var remainingNumber = this.maxLength - this.count($textarea.value);
2022
+ /**
2023
+ * Count the number of characters (or words, if `config.maxwords` is set)
2024
+ * in the given text
2025
+ *
2026
+ * @param {string} text - The text to count the characters of
2027
+ * @returns {number} the number of characters (or words) in the text
2028
+ */
2029
+ CharacterCount.prototype.count = function (text) {
2030
+ if (this.config.maxwords) {
2031
+ var tokens = text.match(/\S+/g) || []; // Matches consecutive non-whitespace chars
2032
+ return tokens.length
2033
+ } else {
2034
+ return text.length
2035
+ }
2036
+ };
2037
+
2038
+ /**
2039
+ * Get count message
2040
+ *
2041
+ * @returns {string} Status message
2042
+ */
2043
+ CharacterCount.prototype.getCountMessage = function () {
2044
+ var remainingNumber = this.maxLength - this.count(this.$textarea.value);
1206
2045
 
1207
- var charVerb = 'remaining';
1208
- var charNoun = 'character';
1209
- var displayNumber = remainingNumber;
1210
- if (options.maxwords) {
1211
- charNoun = 'word';
2046
+ var countType = this.config.maxwords ? 'words' : 'characters';
2047
+ return this.formatCountMessage(remainingNumber, countType)
2048
+ };
2049
+
2050
+ /**
2051
+ * Formats the message shown to users according to what's counted
2052
+ * and how many remain
2053
+ *
2054
+ * @param {number} remainingNumber - The number of words/characaters remaining
2055
+ * @param {string} countType - "words" or "characters"
2056
+ * @returns {string} Status message
2057
+ */
2058
+ CharacterCount.prototype.formatCountMessage = function (remainingNumber, countType) {
2059
+ if (remainingNumber === 0) {
2060
+ return this.i18n.t(countType + 'AtLimit')
1212
2061
  }
1213
- charNoun = charNoun + ((remainingNumber === -1 || remainingNumber === 1) ? '' : 's');
1214
2062
 
1215
- charVerb = (remainingNumber < 0) ? 'too many' : 'remaining';
1216
- displayNumber = Math.abs(remainingNumber);
2063
+ var translationKeySuffix = remainingNumber < 0 ? 'OverLimit' : 'UnderLimit';
1217
2064
 
1218
- return 'You have ' + displayNumber + ' ' + charNoun + ' ' + charVerb
2065
+ return this.i18n.t(countType + translationKeySuffix, { count: Math.abs(remainingNumber) })
1219
2066
  };
1220
2067
 
1221
- // Checks whether the value is over the configured threshold for the input.
1222
- // If there is no configured threshold, it is set to 0 and this function will
1223
- // always return true.
2068
+ /**
2069
+ * Check if count is over threshold
2070
+ *
2071
+ * Checks whether the value is over the configured threshold for the input.
2072
+ * If there is no configured threshold, it is set to 0 and this function will
2073
+ * always return true.
2074
+ *
2075
+ * @returns {boolean} true if the current count is over the config.threshold
2076
+ * (or no threshold is set)
2077
+ */
1224
2078
  CharacterCount.prototype.isOverThreshold = function () {
2079
+ // No threshold means we're always above threshold so save some computation
2080
+ if (!this.config.threshold) {
2081
+ return true
2082
+ }
2083
+
1225
2084
  var $textarea = this.$textarea;
1226
- var options = this.options;
1227
2085
 
1228
2086
  // Determine the remaining number of characters/words
1229
2087
  var currentLength = this.count($textarea.value);
1230
2088
  var maxLength = this.maxLength;
1231
2089
 
1232
- // Set threshold if presented in options
1233
- var thresholdPercent = options.threshold ? options.threshold : 0;
1234
- var thresholdValue = maxLength * thresholdPercent / 100;
2090
+ var thresholdValue = maxLength * this.config.threshold / 100;
1235
2091
 
1236
2092
  return (thresholdValue <= currentLength)
1237
2093
  };
1238
2094
 
1239
- // Update the visible character counter and keep track of when the last update
1240
- // happened for each keypress
1241
- CharacterCount.prototype.handleKeyUp = function () {
1242
- this.updateVisibleCountMessage();
1243
- this.lastInputTimestamp = Date.now();
1244
- };
1245
-
1246
- CharacterCount.prototype.handleFocus = function () {
1247
- // If the field is focused, and a keyup event hasn't been detected for at
1248
- // least 1000 ms (1 second), then run the manual change check.
1249
- // This is so that the update triggered by the manual comparison doesn't
1250
- // conflict with debounced KeyboardEvent updates.
1251
- this.valueChecker = setInterval(function () {
1252
- if (!this.lastInputTimestamp || (Date.now() - 500) >= this.lastInputTimestamp) {
1253
- this.checkIfValueChanged();
1254
- }
1255
- }.bind(this), 1000);
1256
- };
1257
-
1258
- CharacterCount.prototype.handleBlur = function () {
1259
- // Cancel value checking on blur
1260
- clearInterval(this.valueChecker);
1261
- };
2095
+ /**
2096
+ * Character count config
2097
+ *
2098
+ * @typedef {CharacterCountConfigWithMaxLength | CharacterCountConfigWithMaxWords} CharacterCountConfig
2099
+ */
2100
+
2101
+ /**
2102
+ * Character count config (with maximum number of characters)
2103
+ *
2104
+ * @typedef {object} CharacterCountConfigWithMaxLength
2105
+ * @property {number} [maxlength] - The maximum number of characters.
2106
+ * If maxwords is provided, the maxlength option will be ignored.
2107
+ * @property {number} [threshold = 0] - The percentage value of the limit at
2108
+ * which point the count message is displayed. If this attribute is set, the
2109
+ * count message will be hidden by default.
2110
+ * @property {CharacterCountTranslations} [i18n = CHARACTER_COUNT_TRANSLATIONS] - See constant {@link CHARACTER_COUNT_TRANSLATIONS}
2111
+ */
2112
+
2113
+ /**
2114
+ * Character count config (with maximum number of words)
2115
+ *
2116
+ * @typedef {object} CharacterCountConfigWithMaxWords
2117
+ * @property {number} [maxwords] - The maximum number of words. If maxwords is
2118
+ * provided, the maxlength option will be ignored.
2119
+ * @property {number} [threshold = 0] - The percentage value of the limit at
2120
+ * which point the count message is displayed. If this attribute is set, the
2121
+ * count message will be hidden by default.
2122
+ * @property {CharacterCountTranslations} [i18n = CHARACTER_COUNT_TRANSLATIONS] - See constant {@link CHARACTER_COUNT_TRANSLATIONS}
2123
+ */
2124
+
2125
+ /**
2126
+ * Character count translations
2127
+ *
2128
+ * @typedef {object} CharacterCountTranslations
2129
+ *
2130
+ * Messages shown to users as they type. It provides feedback on how many words
2131
+ * or characters they have remaining or if they are over the limit. This also
2132
+ * includes a message used as an accessible description for the textarea.
2133
+ * @property {TranslationPluralForms} [charactersUnderLimit] - Message displayed
2134
+ * when the number of characters is under the configured maximum, `maxlength`.
2135
+ * This message is displayed visually and through assistive technologies. The
2136
+ * component will replace the `%{count}` placeholder with the number of
2137
+ * remaining characters. This is a [pluralised list of
2138
+ * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
2139
+ * @property {string} [charactersAtLimit] - Message displayed when the number of
2140
+ * characters reaches the configured maximum, `maxlength`. This message is
2141
+ * displayed visually and through assistive technologies.
2142
+ * @property {TranslationPluralForms} [charactersOverLimit] - Message displayed
2143
+ * when the number of characters is over the configured maximum, `maxlength`.
2144
+ * This message is displayed visually and through assistive technologies. The
2145
+ * component will replace the `%{count}` placeholder with the number of
2146
+ * remaining characters. This is a [pluralised list of
2147
+ * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
2148
+ * @property {TranslationPluralForms} [wordsUnderLimit] - Message displayed when
2149
+ * the number of words is under the configured maximum, `maxlength`. This
2150
+ * message is displayed visually and through assistive technologies. The
2151
+ * component will replace the `%{count}` placeholder with the number of
2152
+ * remaining words. This is a [pluralised list of
2153
+ * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
2154
+ * @property {string} [wordsAtLimit] - Message displayed when the number of
2155
+ * words reaches the configured maximum, `maxlength`. This message is
2156
+ * displayed visually and through assistive technologies.
2157
+ * @property {TranslationPluralForms} [wordsOverLimit] - Message displayed when
2158
+ * the number of words is over the configured maximum, `maxlength`. This
2159
+ * message is displayed visually and through assistive technologies. The
2160
+ * component will replace the `%{count}` placeholder with the number of
2161
+ * remaining words. This is a [pluralised list of
2162
+ * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
2163
+ * @property {TranslationPluralForms} [textareaDescription] - Message made
2164
+ * available to assistive technologies, if none is already present in the
2165
+ * HTML, to describe that the component accepts only a limited amount of
2166
+ * content. It is visible on the page when JavaScript is unavailable. The
2167
+ * component will replace the `%{count}` placeholder with the value of the
2168
+ * `maxlength` or `maxwords` parameter.
2169
+ */
2170
+
2171
+ /**
2172
+ * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
2173
+ */
1262
2174
 
1263
2175
  return CharacterCount;
1264
2176