Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow CharacterCount component to receive i18n config via JS #2887

Merged
merged 14 commits into from
Oct 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the polyfill definitely go here?

Suggested change
import './vendor/polyfills/Element/prototype/closest.mjs'

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to merge with this line, we're going to address it before the next release under #2907


/**
* 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) {
romaricpascal marked this conversation as resolved.
Show resolved Hide resolved
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', () => {
romaricpascal marked this conversation as resolved.
Show resolved Hide resolved
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'
romaricpascal marked this conversation as resolved.
Show resolved Hide resolved
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"]
romaricpascal marked this conversation as resolved.
Show resolved Hide resolved
* 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) {
romaricpascal marked this conversation as resolved.
Show resolved Hide resolved
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) })
colinrotherham marked this conversation as resolved.
Show resolved Hide resolved
}

/**
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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super minor one, but any thoughts on logically grouping what the test is doing?

const $parent = document.createElement('div')
const $div = document.createElement('div')

$parent.setAttribute('lang', 'de')
$parent.appendChild($div)

const component = new CharacterCount($div)
const countMessage = component.formatCountMessage(10000, 'words')

expect(countMessage).toEqual('You have 10.000 words remaining')
  1. Create things
  2. Customise things
  3. Run some code
  4. Run assertion

Helps with readability slightly, not a blocker though

})
})

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