diff --git a/src/govuk/common.mjs b/src/govuk/common.mjs
index 22f23ddd53..ff3c537e87 100644
--- a/src/govuk/common.mjs
+++ b/src/govuk/common.mjs
@@ -1,5 +1,6 @@
import './vendor/polyfills/Element/prototype/dataset.mjs'
import './vendor/polyfills/String/prototype/trim.mjs'
+import './vendor/polyfills/Element/prototype/closest.mjs'
/**
* TODO: Ideally this would be a NodeList.prototype.forEach polyfill
@@ -186,3 +187,17 @@ export function normaliseDataset (dataset) {
return out
}
+
+/**
+ * Returns the value of the given attribute closest to the given element (including itself)
+ *
+ * @param {HTMLElement} $element - The element to start walking the DOM tree up
+ * @param {String} attributeName - The name of the attribute
+ * @returns {String|undefined}
+ */
+export function closestAttributeValue ($element, attributeName) {
+ var closestElementWithAttribute = $element.closest('[' + attributeName + ']')
+ if (closestElementWithAttribute) {
+ return closestElementWithAttribute.getAttribute(attributeName)
+ }
+}
diff --git a/src/govuk/common.unit.test.mjs b/src/govuk/common.unit.test.mjs
index 83338d5e7c..5c0ce893e1 100644
--- a/src/govuk/common.unit.test.mjs
+++ b/src/govuk/common.unit.test.mjs
@@ -2,7 +2,7 @@
* @jest-environment jsdom
*/
-import { mergeConfigs, extractConfigByNamespace, normaliseString, normaliseDataset } from './common.mjs'
+import { mergeConfigs, extractConfigByNamespace, normaliseString, normaliseDataset, closestAttributeValue } from './common.mjs'
// TODO: Write unit tests for `nodeListForEach` and `generateUniqueID`
@@ -216,4 +216,38 @@ describe('Common JS utilities', () => {
})
})
})
+
+ describe('closestAttributeValue', () => {
+ it('returns the value of the attribute if on the element', () => {
+ const $element = document.createElement('div')
+ $element.setAttribute('lang', 'en-GB')
+
+ expect(closestAttributeValue($element, 'lang')).toEqual('en-GB')
+ })
+
+ it('returns the value of the closest parent with the attribute if it exists', () => {
+ const template = document.createElement('template')
+ template.innerHTML = `
+
+ `
+ const dom = template.content.cloneNode(true)
+ const $element = dom.querySelector('.target')
+
+ expect(closestAttributeValue($element, 'lang')).toEqual('en-GB')
+ })
+
+ it('returns undefined if neither the element or a parent have the attribute', () => {
+ const $parent = document.createElement('div')
+ const $element = document.createElement('div')
+ $parent.appendChild($element)
+
+ expect(closestAttributeValue($element, 'lang')).toBeUndefined()
+ })
+ })
})
diff --git a/src/govuk/components/character-count/character-count.mjs b/src/govuk/components/character-count/character-count.mjs
index a60d90aa7e..1fb5ac3b17 100644
--- a/src/govuk/components/character-count/character-count.mjs
+++ b/src/govuk/components/character-count/character-count.mjs
@@ -2,7 +2,8 @@ import '../../vendor/polyfills/Date/now.mjs'
import '../../vendor/polyfills/Function/prototype/bind.mjs'
import '../../vendor/polyfills/Event.mjs' // addEventListener and event.target normalisation
import '../../vendor/polyfills/Element/prototype/classList.mjs'
-import { mergeConfigs, normaliseDataset } from '../../common.mjs'
+import { closestAttributeValue, extractConfigByNamespace, mergeConfigs, normaliseDataset } from '../../common.mjs'
+import { I18n } from '../../i18n.mjs'
/**
* JavaScript enhancements for the CharacterCount component
@@ -24,6 +25,27 @@ import { mergeConfigs, normaliseDataset } from '../../common.mjs'
* @param {Number} [config.threshold=0] - The percentage value of the limit at
* which point the count message is displayed. If this attribute is set, the
* count message will be hidden by default.
+ * @param {Object} [config.i18n]
+ * @param {String} [config.i18n.charactersUnderLimitOne="You have %{count} character remaining"]
+ * Message notifying users they're 1 character under the limit
+ * @param {String} [config.i18n.charactersUnderLimitOther="You have %{count} characters remaining"]
+ * Message notifying users they're any number of characters under the limit
+ * @param {String} [config.i18n.charactersAtLimit="You have 0 characters remaining"]
+ * Message notifying users they've reached the limit number of characters
+ * @param {String} [config.i18n.charactersOverLimitOne="You have %{count} character too many"]
+ * Message notifying users they're 1 character over the limit
+ * @param {String} [config.i18n.charactersOverLimitOther="You have %{count} characters too many"]
+ * Message notifying users they're any number of characters over the limit
+ * @param {String} [config.i18n.wordsUnderLimitOne="You have %{count} word remaining"]
+ * Message notifying users they're 1 word under the limit
+ * @param {String} [config.i18n.wordsUnderLimitOther="You have %{count} words remaining"]
+ * Message notifying users they're any number of words under the limit
+ * @param {String} [config.i18n.wordsAtLimit="You have 0 words remaining"]
+ * Message notifying users they've reached the limit number of words
+ * @param {String} [config.i18n.wordsOverLimitOne="You have %{count} word too many"]
+ * Message notifying users they're 1 word over the limit
+ * @param {String} [config.i18n.wordsOverLimitOther="You have %{count} words too many"]
+ * Message notifying users they're any number of words over the limit
*/
function CharacterCount ($module, config) {
if (!$module) {
@@ -31,7 +53,21 @@ function CharacterCount ($module, config) {
}
var defaultConfig = {
- threshold: 0
+ threshold: 0,
+ i18n: {
+ // Characters
+ charactersUnderLimitOne: 'You have %{count} character remaining',
+ charactersUnderLimitOther: 'You have %{count} characters remaining',
+ charactersAtLimit: 'You have 0 characters remaining',
+ charactersOverLimitOne: 'You have %{count} character too many',
+ charactersOverLimitOther: 'You have %{count} characters too many',
+ // Words
+ wordsUnderLimitOne: 'You have %{count} word remaining',
+ wordsUnderLimitOther: 'You have %{count} words remaining',
+ wordsAtLimit: 'You have 0 words remaining',
+ wordsOverLimitOne: 'You have %{count} word too many',
+ wordsOverLimitOther: 'You have %{count} words too many'
+ }
}
// Read config set using dataset ('data-' values)
@@ -58,6 +94,11 @@ function CharacterCount ($module, config) {
datasetConfig
)
+ this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'), {
+ // Read the fallback if necessary rather than have it set in the defaults
+ locale: closestAttributeValue($module, 'lang')
+ })
+
// Determine the limit attribute (characters or words)
if (this.config.maxwords) {
this.maxLength = this.config.maxwords
@@ -278,22 +319,28 @@ CharacterCount.prototype.count = function (text) {
* @returns {String} Status message
*/
CharacterCount.prototype.getCountMessage = function () {
- var $textarea = this.$textarea
- var config = this.config
- var remainingNumber = this.maxLength - this.count($textarea.value)
+ var remainingNumber = this.maxLength - this.count(this.$textarea.value)
- var charVerb = 'remaining'
- var charNoun = 'character'
- var displayNumber = remainingNumber
- if (config.maxwords) {
- charNoun = 'word'
+ var countType = this.config.maxwords ? 'words' : 'characters'
+ return this.formatCountMessage(remainingNumber, countType)
+}
+
+/**
+ * Formats the message shown to users according to what's counted
+ * and how many remain
+ *
+ * @param {Number} remainingNumber - The number of words/characaters remaining
+ * @param {String} countType - "words" or "characters"
+ * @returns String
+ */
+CharacterCount.prototype.formatCountMessage = function (remainingNumber, countType) {
+ if (remainingNumber === 0) {
+ return this.i18n.t(countType + 'AtLimit')
}
- charNoun = charNoun + ((remainingNumber === -1 || remainingNumber === 1) ? '' : 's')
- charVerb = (remainingNumber < 0) ? 'too many' : 'remaining'
- displayNumber = Math.abs(remainingNumber)
+ var translationKeySuffix = remainingNumber < 0 ? 'OverLimit' : 'UnderLimit'
- return 'You have ' + displayNumber + ' ' + charNoun + ' ' + charVerb
+ return this.i18n.t(countType + translationKeySuffix, { count: Math.abs(remainingNumber) })
}
/**
diff --git a/src/govuk/components/character-count/character-count.unit.test.mjs b/src/govuk/components/character-count/character-count.unit.test.mjs
new file mode 100644
index 0000000000..82a788d2c5
--- /dev/null
+++ b/src/govuk/components/character-count/character-count.unit.test.mjs
@@ -0,0 +1,119 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import CharacterCount from './character-count.mjs'
+
+describe('CharacterCount', () => {
+ describe('formatCountMessage', () => {
+ describe('default configuration', () => {
+ let component
+ beforeAll(() => {
+ // The component won't initialise if we don't pass it an element
+ component = new CharacterCount(document.createElement('div'))
+ })
+
+ const cases = [
+ { number: 1, type: 'characters', expected: 'You have 1 character remaining' },
+ { number: 10, type: 'characters', expected: 'You have 10 characters remaining' },
+ { number: -1, type: 'characters', expected: 'You have 1 character too many' },
+ { number: -10, type: 'characters', expected: 'You have 10 characters too many' },
+ { number: 0, type: 'characters', expected: 'You have 0 characters remaining' },
+ { number: 1, type: 'words', expected: 'You have 1 word remaining' },
+ { number: 10, type: 'words', expected: 'You have 10 words remaining' },
+ { number: -1, type: 'words', expected: 'You have 1 word too many' },
+ { number: -10, type: 'words', expected: 'You have 10 words too many' },
+ { number: 0, type: 'words', expected: 'You have 0 words remaining' }
+ ]
+ it.each(cases)(
+ 'picks the relevant translation for $number $type',
+ function test ({ number, type, expected }) {
+ expect(component.formatCountMessage(number, type)).toEqual(expected)
+ }
+ )
+
+ it('formats the number inserted in the message', () => {
+ expect(component.formatCountMessage(10000, 'words')).toEqual('You have 10,000 words remaining')
+ expect(component.formatCountMessage(-10000, 'words')).toEqual('You have 10,000 words too many')
+ })
+ })
+
+ describe('i18n', () => {
+ describe('JavaScript configuration', () => {
+ it('overrides the default translation keys', () => {
+ const component = new CharacterCount(document.createElement('div'), {
+ i18n: { charactersUnderLimitOne: 'Custom text. Count: %{count}' },
+ 'i18n.charactersOverLimitOther': 'Different custom text. Count: %{count}'
+ })
+
+ expect(component.formatCountMessage(1, 'characters')).toEqual('Custom text. Count: 1')
+ expect(component.formatCountMessage(-10, 'characters')).toEqual('Different custom text. Count: 10')
+ // Other keys remain untouched
+ expect(component.formatCountMessage(10, 'characters')).toEqual('You have 10 characters remaining')
+ })
+
+ it('uses specific keys for when limit is reached', () => {
+ const component = new CharacterCount(document.createElement('div'), {
+ i18n: { charactersAtLimit: 'Custom text.' },
+ 'i18n.wordsAtLimit': 'Different custom text.'
+ })
+
+ expect(component.formatCountMessage(0, 'characters')).toEqual('Custom text.')
+ expect(component.formatCountMessage(0, 'words')).toEqual('Different custom text.')
+ })
+ })
+
+ describe('lang attribute configuration', () => {
+ it('overrides the locale when set on the element', () => {
+ const $div = document.createElement('div')
+ $div.setAttribute('lang', 'de')
+
+ const component = new CharacterCount($div)
+
+ expect(component.formatCountMessage(10000, 'words')).toEqual('You have 10.000 words remaining')
+ })
+
+ it('overrides the locale when set on an ancestor', () => {
+ const $parent = document.createElement('div')
+ $parent.setAttribute('lang', 'de')
+
+ const $div = document.createElement('div')
+ $parent.appendChild($div)
+
+ const component = new CharacterCount($div)
+
+ expect(component.formatCountMessage(10000, 'words')).toEqual('You have 10.000 words remaining')
+ })
+ })
+
+ describe('Data attribute configuration', () => {
+ it('overrides the default translation keys', () => {
+ const $div = document.createElement('div')
+ $div.setAttribute('data-i18n.characters-under-limit-one', 'Custom text. Count: %{count}')
+
+ const component = new CharacterCount($div)
+
+ expect(component.formatCountMessage(1, 'characters')).toEqual('Custom text. Count: 1')
+ // Other keys remain untouched
+ expect(component.formatCountMessage(10, 'characters')).toEqual('You have 10 characters remaining')
+ })
+
+ describe('precedence over JavaScript configuration', () => {
+ it('overrides translation keys', () => {
+ const $div = document.createElement('div')
+ $div.setAttribute('data-i18n.characters-under-limit-one', 'Custom text. Count: %{count}')
+
+ const component = new CharacterCount($div, {
+ i18n: {
+ charactersUnderLimitOne: 'Different custom text. Count: %{count}'
+ }
+ })
+ expect(component.formatCountMessage(1, 'characters')).toEqual('Custom text. Count: 1')
+ // Other keys remain untouched
+ expect(component.formatCountMessage(10, 'characters')).toEqual('You have 10 characters remaining')
+ })
+ })
+ })
+ })
+ })
+})