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