govuk_tech_docs 3.2.0 → 3.3.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of govuk_tech_docs might be problematic. Click here for more details.

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,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,114 +1028,930 @@ 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) {
1018
- this.$module = $module;
1019
- this.$textarea = $module.querySelector('.govuk-js-character-count');
1020
- if (this.$textarea) {
1021
- this.$countMessage = document.getElementById(this.$textarea.id + '-info');
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
+ }
1022
1105
  }
1106
+
1107
+ return formattedConfigObject
1023
1108
  }
1024
1109
 
1025
- CharacterCount.prototype.defaults = {
1026
- characterCountAttribute: 'data-maxlength',
1027
- wordCountAttribute: 'data-maxwords'
1028
- };
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
+ }
1029
1145
 
1030
- // Initialize component
1031
- CharacterCount.prototype.init = function () {
1032
- // Check for module
1033
- var $module = this.$module;
1034
- var $textarea = this.$textarea;
1035
- var $countMessage = this.$countMessage;
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
+ }
1036
1171
 
1037
- if (!$textarea || !$countMessage) {
1038
- return
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')
1039
1184
  }
1040
1185
 
1041
- // We move count message right after the field
1042
- // Kept for backwards compatibility
1043
- $textarea.insertAdjacentElement('afterend', $countMessage);
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
+ }
1044
1193
 
1045
- // Read options set using dataset ('data-' values)
1046
- this.options = this.getDataset($module);
1194
+ if (lookupKey in this.translations) {
1195
+ // Fetch the translation string for that lookup key
1196
+ var translationString = this.translations[lookupKey];
1047
1197
 
1048
- // Determine the limit attribute (characters or words)
1049
- var countAttribute = this.defaults.characterCountAttribute;
1050
- if (this.options.maxwords) {
1051
- countAttribute = this.defaults.wordCountAttribute;
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
1052
1212
  }
1213
+ };
1053
1214
 
1054
- // Save the element limit
1055
- this.maxLength = $module.getAttribute(countAttribute);
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
+ }
1056
1229
 
1057
- // Check for limit
1058
- if (!this.maxLength) {
1059
- return
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);
1060
1311
  }
1061
1312
 
1062
- // Remove hard limit if set
1063
- $module.removeAttribute('maxlength');
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
+ }
1064
1323
 
1065
- // When the page is restored after navigating 'back' in some browsers the
1066
- // state of the character count is not restored until *after* the DOMContentLoaded
1067
- // event is fired, so we need to sync after the pageshow event in browsers
1068
- // that support it.
1069
- if ('onpageshow' in window) {
1070
- window.addEventListener('pageshow', this.sync.bind(this));
1324
+ return 'other'
1325
+ // If the required `other` plural form is missing, all we can do is error
1071
1326
  } else {
1072
- window.addEventListener('DOMContentLoaded', this.sync.bind(this));
1327
+ throw new Error(
1328
+ 'i18n: Plural form ".other" is required for "' + this.locale + '" locale'
1329
+ )
1073
1330
  }
1331
+ };
1074
1332
 
1075
- this.sync();
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'
1076
1354
  };
1077
1355
 
1078
- CharacterCount.prototype.sync = function () {
1079
- this.bindChangeEvents();
1080
- this.updateCountMessage();
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
+ }
1081
1383
  };
1082
1384
 
1083
- // Read data attributes
1084
- CharacterCount.prototype.getDataset = function (element) {
1085
- var dataset = {};
1086
- var attributes = element.attributes;
1087
- if (attributes) {
1088
- for (var i = 0; i < attributes.length; i++) {
1089
- var attribute = attributes[i];
1090
- var match = attribute.name.match(/^data-(.+)/);
1091
- if (match) {
1092
- dataset[match[1]] = attribute.value;
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
+ }
1093
1593
  }
1594
+
1595
+ return map;
1094
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: ''
1095
1761
  }
1096
- return dataset
1097
1762
  };
1098
1763
 
1099
- // Counts characters or words in text
1100
- CharacterCount.prototype.count = function (text) {
1101
- var length;
1102
- if (this.options.maxwords) {
1103
- var tokens = text.match(/\S+/g) || []; // Matches consecutive non-whitespace chars
1104
- length = tokens.length;
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
+
1826
+ this.$module = $module;
1827
+ this.$textarea = $module.querySelector('.govuk-js-character-count');
1828
+ this.$visibleCountMessage = null;
1829
+ this.$screenReaderCountMessage = null;
1830
+ this.lastInputTimestamp = null;
1831
+ }
1832
+
1833
+ /**
1834
+ * Initialise component
1835
+ */
1836
+ CharacterCount.prototype.init = function () {
1837
+ // Check that required elements are present
1838
+ if (!this.$textarea) {
1839
+ return
1840
+ }
1841
+
1842
+ var $textarea = this.$textarea;
1843
+ var $textareaDescription = document.getElementById($textarea.id + '-info');
1844
+
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
1853
+ // Kept for backwards compatibility
1854
+ $textarea.insertAdjacentElement('afterend', $textareaDescription);
1855
+
1856
+ // Create the *screen reader* specific live-updating counter
1857
+ // This doesn't need any styling classes, as it is never visible
1858
+ var $screenReaderCountMessage = document.createElement('div');
1859
+ $screenReaderCountMessage.className = 'govuk-character-count__sr-status govuk-visually-hidden';
1860
+ $screenReaderCountMessage.setAttribute('aria-live', 'polite');
1861
+ this.$screenReaderCountMessage = $screenReaderCountMessage;
1862
+ $textareaDescription.insertAdjacentElement('afterend', $screenReaderCountMessage);
1863
+
1864
+ // Create our live-updating counter element, copying the classes from the
1865
+ // textarea description for backwards compatibility as these may have been
1866
+ // configured
1867
+ var $visibleCountMessage = document.createElement('div');
1868
+ $visibleCountMessage.className = $textareaDescription.className;
1869
+ $visibleCountMessage.classList.add('govuk-character-count__status');
1870
+ $visibleCountMessage.setAttribute('aria-hidden', 'true');
1871
+ this.$visibleCountMessage = $visibleCountMessage;
1872
+ $textareaDescription.insertAdjacentElement('afterend', $visibleCountMessage);
1873
+
1874
+ // Hide the textarea description
1875
+ $textareaDescription.classList.add('govuk-visually-hidden');
1876
+
1877
+ // Remove hard limit if set
1878
+ $textarea.removeAttribute('maxlength');
1879
+
1880
+ this.bindChangeEvents();
1881
+
1882
+ // When the page is restored after navigating 'back' in some browsers the
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.
1886
+ if ('onpageshow' in window) {
1887
+ window.addEventListener('pageshow', this.updateCountMessage.bind(this));
1105
1888
  } else {
1106
- length = text.length;
1889
+ window.addEventListener('DOMContentLoaded', this.updateCountMessage.bind(this));
1107
1890
  }
1108
- return length
1891
+ this.updateCountMessage();
1109
1892
  };
1110
1893
 
1111
- // 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
+ */
1112
1900
  CharacterCount.prototype.bindChangeEvents = function () {
1113
1901
  var $textarea = this.$textarea;
1114
- $textarea.addEventListener('keyup', this.checkIfValueChanged.bind(this));
1902
+ $textarea.addEventListener('keyup', this.handleKeyUp.bind(this));
1115
1903
 
1116
1904
  // Bind focus/blur events to start/stop polling
1117
1905
  $textarea.addEventListener('focus', this.handleFocus.bind(this));
1118
1906
  $textarea.addEventListener('blur', this.handleBlur.bind(this));
1119
1907
  };
1120
1908
 
1121
- // Speech recognition software such as Dragon NaturallySpeaking will modify the
1122
- // fields by directly changing its `value`. These changes don't trigger events
1123
- // in JavaScript, so we need to poll to handle when and if they occur.
1124
- 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 () {
1125
1955
  if (!this.$textarea.oldValue) this.$textarea.oldValue = '';
1126
1956
  if (this.$textarea.value !== this.$textarea.oldValue) {
1127
1957
  this.$textarea.oldValue = this.$textarea.value;
@@ -1129,66 +1959,219 @@ CharacterCount.prototype.checkIfValueChanged = function () {
1129
1959
  }
1130
1960
  };
1131
1961
 
1132
- // Update message box
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
+ */
1133
1968
  CharacterCount.prototype.updateCountMessage = function () {
1134
- var countElement = this.$textarea;
1135
- var options = this.options;
1136
- var countMessage = this.$countMessage;
1969
+ this.updateVisibleCountMessage();
1970
+ this.updateScreenReaderCountMessage();
1971
+ };
1137
1972
 
1138
- // Determine the remaining number of characters/words
1139
- var currentLength = this.count(countElement.value);
1140
- var maxLength = this.maxLength;
1141
- var remainingNumber = maxLength - currentLength;
1142
-
1143
- // Set threshold if presented in options
1144
- var thresholdPercent = options.threshold ? options.threshold : 0;
1145
- var thresholdValue = maxLength * thresholdPercent / 100;
1146
- if (thresholdValue > currentLength) {
1147
- countMessage.classList.add('govuk-character-count__message--disabled');
1148
- // Ensure threshold is hidden for users of assistive technologies
1149
- countMessage.setAttribute('aria-hidden', true);
1973
+ /**
1974
+ * Update visible count message
1975
+ */
1976
+ CharacterCount.prototype.updateVisibleCountMessage = function () {
1977
+ var $textarea = this.$textarea;
1978
+ var $visibleCountMessage = this.$visibleCountMessage;
1979
+ var remainingNumber = this.maxLength - this.count($textarea.value);
1980
+
1981
+ // If input is over the threshold, remove the disabled class which renders the
1982
+ // counter invisible.
1983
+ if (this.isOverThreshold()) {
1984
+ $visibleCountMessage.classList.remove('govuk-character-count__message--disabled');
1150
1985
  } else {
1151
- countMessage.classList.remove('govuk-character-count__message--disabled');
1152
- // Ensure threshold is visible for users of assistive technologies
1153
- countMessage.removeAttribute('aria-hidden');
1986
+ $visibleCountMessage.classList.add('govuk-character-count__message--disabled');
1154
1987
  }
1155
1988
 
1156
1989
  // Update styles
1157
1990
  if (remainingNumber < 0) {
1158
- countElement.classList.add('govuk-textarea--error');
1159
- countMessage.classList.remove('govuk-hint');
1160
- countMessage.classList.add('govuk-error-message');
1991
+ $textarea.classList.add('govuk-textarea--error');
1992
+ $visibleCountMessage.classList.remove('govuk-hint');
1993
+ $visibleCountMessage.classList.add('govuk-error-message');
1994
+ } else {
1995
+ $textarea.classList.remove('govuk-textarea--error');
1996
+ $visibleCountMessage.classList.remove('govuk-error-message');
1997
+ $visibleCountMessage.classList.add('govuk-hint');
1998
+ }
1999
+
2000
+ // Update message
2001
+ $visibleCountMessage.innerText = this.getCountMessage();
2002
+ };
2003
+
2004
+ /**
2005
+ * Update screen reader count message
2006
+ */
2007
+ CharacterCount.prototype.updateScreenReaderCountMessage = function () {
2008
+ var $screenReaderCountMessage = this.$screenReaderCountMessage;
2009
+
2010
+ // If over the threshold, remove the aria-hidden attribute, allowing screen
2011
+ // readers to announce the content of the element.
2012
+ if (this.isOverThreshold()) {
2013
+ $screenReaderCountMessage.removeAttribute('aria-hidden');
1161
2014
  } else {
1162
- countElement.classList.remove('govuk-textarea--error');
1163
- countMessage.classList.remove('govuk-error-message');
1164
- countMessage.classList.add('govuk-hint');
2015
+ $screenReaderCountMessage.setAttribute('aria-hidden', true);
1165
2016
  }
1166
2017
 
1167
2018
  // Update message
1168
- var charVerb = 'remaining';
1169
- var charNoun = 'character';
1170
- var displayNumber = remainingNumber;
1171
- if (options.maxwords) {
1172
- charNoun = 'word';
2019
+ $screenReaderCountMessage.innerText = this.getCountMessage();
2020
+ };
2021
+
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
1173
2035
  }
1174
- charNoun = charNoun + ((remainingNumber === -1 || remainingNumber === 1) ? '' : 's');
2036
+ };
1175
2037
 
1176
- charVerb = (remainingNumber < 0) ? 'too many' : 'remaining';
1177
- displayNumber = Math.abs(remainingNumber);
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);
1178
2045
 
1179
- countMessage.innerHTML = 'You have ' + displayNumber + ' ' + charNoun + ' ' + charVerb;
2046
+ var countType = this.config.maxwords ? 'words' : 'characters';
2047
+ return this.formatCountMessage(remainingNumber, countType)
1180
2048
  };
1181
2049
 
1182
- CharacterCount.prototype.handleFocus = function () {
1183
- // Check if value changed on focus
1184
- this.valueChecker = setInterval(this.checkIfValueChanged.bind(this), 1000);
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')
2061
+ }
2062
+
2063
+ var translationKeySuffix = remainingNumber < 0 ? 'OverLimit' : 'UnderLimit';
2064
+
2065
+ return this.i18n.t(countType + translationKeySuffix, { count: Math.abs(remainingNumber) })
1185
2066
  };
1186
2067
 
1187
- CharacterCount.prototype.handleBlur = function () {
1188
- // Cancel value checking on blur
1189
- clearInterval(this.valueChecker);
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
+ */
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
+
2084
+ var $textarea = this.$textarea;
2085
+
2086
+ // Determine the remaining number of characters/words
2087
+ var currentLength = this.count($textarea.value);
2088
+ var maxLength = this.maxLength;
2089
+
2090
+ var thresholdValue = maxLength * this.config.threshold / 100;
2091
+
2092
+ return (thresholdValue <= currentLength)
1190
2093
  };
1191
2094
 
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
+ */
2174
+
1192
2175
  return CharacterCount;
1193
2176
 
1194
2177
  })));