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') + }) + }) + }) + }) + }) +})