Skip to content

Commit

Permalink
Merge pull request #2887 from alphagov/character-count-i18n
Browse files Browse the repository at this point in the history
  • Loading branch information
romaricpascal committed Oct 11, 2022
2 parents f199990 + a5ed606 commit 87f5314
Show file tree
Hide file tree
Showing 4 changed files with 230 additions and 15 deletions.
15 changes: 15 additions & 0 deletions src/govuk/common.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
36 changes: 35 additions & 1 deletion src/govuk/common.unit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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 = `
<div lang="cy-GB"><!-- To check that we take the first value up -->
<div lang="en-GB"><!-- The value we should get -->
<div><!-- To check that we walk up the tree -->
<div class="target"></div>
</div>
</div>
</div>
`
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()
})
})
})
75 changes: 61 additions & 14 deletions src/govuk/components/character-count/character-count.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,14 +25,49 @@ 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) {
return this
}

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)
Expand All @@ -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
Expand Down Expand Up @@ -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) })
}

/**
Expand Down
119 changes: 119 additions & 0 deletions src/govuk/components/character-count/character-count.unit.test.mjs
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
})
})
})

0 comments on commit 87f5314

Please sign in to comment.