diff --git a/CHANGELOG.md b/CHANGELOG.md index 86c956df9b..13ae0a718d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ ([PR #1015](https://github.com/alphagov/govuk-frontend/pull/1015)) +- Add character count component + + ([PR #959](https://github.com/alphagov/govuk-frontend/pull/959)) + + 🔧 Fixes: - Apply max-width to the ` + + + + You can enter up to 10 characters + + + +#### Macro + + {% from "character-count/macro.njk" import govukCharacterCount %} + + {{ govukCharacterCount({ + "name": "more-detail", + "id": "more-detail", + "maxlength": 10, + "label": { + "text": "Can you provide more detail?" + } + }) }} + +### Character count with hint + +[Preview this example in the Frontend review app](http://govuk-frontend-review.herokuapp.com/components/character-count/with-hint/preview) + +#### Markup + +
+ +
+ + + + Don't include personal or financial information, eg your National Insurance number or credit card details. + + + +
+ + + You can enter up to 10 characters + +
+ +#### Macro + + {% from "character-count/macro.njk" import govukCharacterCount %} + + {{ govukCharacterCount({ + "name": "with-hint", + "id": "with-hint", + "maxlength": 10, + "label": { + "text": "Can you provide more detail?" + }, + "hint": { + "text": "Don't include personal or financial information, eg your National Insurance number or credit card details." + } + }) }} + +### Character count with default value + +[Preview this example in the Frontend review app](http://govuk-frontend-review.herokuapp.com/components/character-count/with-default-value/preview) + +#### Markup + +
+ +
+ + + +
+ + + You can enter up to 100 characters + +
+ +#### Macro + + {% from "character-count/macro.njk" import govukCharacterCount %} + + {{ govukCharacterCount({ + "id": "with-default-value", + "name": "default-value", + "maxlength": 100, + "label": { + "text": "Full address" + }, + "value": "221B Baker Street\nLondon\nNW1 6XE\n" + }) }} + +### Character count with default value exceeding limit + +[Preview this example in the Frontend review app](http://govuk-frontend-review.herokuapp.com/components/character-count/with-default-value-exceeding-limit/preview) + +#### Markup + +
+ +
+ + + + Please do not exceed the maximum allowed limit + + + +
+ + + You can enter up to 10 characters + +
+ +#### Macro + + {% from "character-count/macro.njk" import govukCharacterCount %} + + {{ govukCharacterCount({ + "id": "exceeding-characters", + "name": "exceeding", + "maxlength": 10, + "value": "221B Baker Street\nLondon\nNW1 6XE\n", + "label": { + "text": "Full address" + }, + "errorMessage": { + "text": "Please do not exceed the maximum allowed limit" + } + }) }} + +### Character count with custom rows + +[Preview this example in the Frontend review app](http://govuk-frontend-review.herokuapp.com/components/character-count/with-custom-rows/preview) + +#### Markup + +
+ +
+ + + +
+ + + You can enter up to 10 characters + +
+ +#### Macro + + {% from "character-count/macro.njk" import govukCharacterCount %} + + {{ govukCharacterCount({ + "id": "custom-rows", + "name": "custom", + "maxlength": 10, + "label": { + "text": "Full address" + }, + "rows": 8 + }) }} + +### Character count with label as page heading + +[Preview this example in the Frontend review app](http://govuk-frontend-review.herokuapp.com/components/character-count/with-label-as-page-heading/preview) + +#### Markup + +
+ +
+

+ + +

+ + +
+ + + You can enter up to 10 characters + +
+ +#### Macro + + {% from "character-count/macro.njk" import govukCharacterCount %} + + {{ govukCharacterCount({ + "id": "textarea-with-page-heading", + "name": "address", + "maxlength": 10, + "label": { + "text": "Full address", + "isPageHeading": true + } + }) }} + +### Character count with word count + +[Preview this example in the Frontend review app](http://govuk-frontend-review.herokuapp.com/components/character-count/with-word-count/preview) + +#### Markup + +
+ +
+ + + +
+ + + You can enter up to 10 words + +
+ +#### Macro + + {% from "character-count/macro.njk" import govukCharacterCount %} + + {{ govukCharacterCount({ + "id": "word-count", + "name": "word-count", + "maxwords": 10, + "label": { + "text": "Full address" + } + }) }} + +### Character count with threshold + +[Preview this example in the Frontend review app](http://govuk-frontend-review.herokuapp.com/components/character-count/with-threshold/preview) + +#### Markup + +
+ +
+ + + +
+ + + You can enter up to 10 characters + +
+ +#### Macro + + {% from "character-count/macro.njk" import govukCharacterCount %} + + {{ govukCharacterCount({ + "id": "with-threshold", + "name": "with-threshold", + "maxlength": 10, + "threshold": 75, + "label": { + "text": "Full address" + } + }) }} + +## Requirements + +### Build tool configuration + +When compiling the Sass files you'll need to define includePaths to reference the node_modules directory. Below is a sample configuration using gulp + + .pipe(sass({ + includePaths: 'node_modules/' + })) + +### Static asset path configuration + +In order to include the images used in the components, you need to configure your app to show these assets. Below is a sample configuration using Express js: + + app.use('/assets', express.static(path.join(__dirname, '/node_modules/govuk-frontend/assets'))) + +## Component arguments + +If you are using Nunjucks,then macros take the following arguments + +**If you’re using Nunjucks macros in production be aware that using `html` arguments, or ones ending with `Html` can be a [security risk](https://en.wikipedia.org/wiki/Cross-site_scripting). More about it in the [Nunjucks documentation](https://mozilla.github.io/nunjucks/api.html#user-defined-templates-warning).** + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeRequiredDescription
idstringYesThe id of the textarea
describedBystringNoText or element id to add to the `aria-describedby` attribute to provide description for screenreader users.
namestringYesThe name of the textarea, which is submitted with the form data.
rowsstringNoOptional number of textarea rows (default is 5 rows).
valuestringNoOptional initial value of the textarea.
labelobjectYesArguments for the label component. See label component.
hintobjectNoArguments for the hint component (e.g. text). See hint component.
errorMessageobjectNoArguments for the errorMessage component (e.g. text). See errorMessage component.
classesstringNoOptional additional classes to add to the textarea tag.
attributesobjectNoAny extra HTML attributes (for example data attributes) to add to the textarea tag.
+ +**If you’re using Nunjucks macros in production be aware that using `html` arguments, or ones ending with `Html` can be a [security risk](https://en.wikipedia.org/wiki/Cross-site_scripting). More about it in the [Nunjucks documentation](https://mozilla.github.io/nunjucks/api.html#user-defined-templates-warning).** + +### Setting up Nunjucks views and paths + +Below is an example setup using express configure views: + + nunjucks.configure('node_modules/govuk-frontend/components', { + autoescape: true, + cache: false, + express: app + }) + +## Contribution + +Guidelines can be found at [on our Github repository.](https://github.com/alphagov/govuk-frontend/blob/master/CONTRIBUTING.md "link to contributing guidelines on our github repository") + +## License + +MIT \ No newline at end of file diff --git a/src/components/character-count/README.njk b/src/components/character-count/README.njk new file mode 100644 index 0000000000..786245ad3f --- /dev/null +++ b/src/components/character-count/README.njk @@ -0,0 +1,177 @@ +{% extends "readme.njk" %} + +{# Commented out blocks below inherit from views/component.njk #} + +{# componentName #} + +{% block componentDescription %} + Help users enter text within a limited number of characters +{% endblock %} + +{# examples #} + +{# override link to design system here if it's different to base url + componentName #} +{# {% set componentGuidanceLink = 'new link here' %} #} + +{% block componentArguments %} +{{ govukTable({ + 'firstCellIsHeader': true, + 'head' : [ + { + text: 'Name' + }, + { + text: 'Type' + }, + { + text: 'Required' + }, + { + text: 'Description' + } + ], + 'rows' : [ + + [ + { + text: 'id' + }, + { + text: 'string' + }, + { + text: 'Yes' + }, + { + text: 'The id of the textarea' + } + ], + [ + { + text: 'describedBy' + }, + { + text: 'string' + }, + { + text: 'No' + }, + { + text: 'Text or element id to add to the `aria-describedby` attribute to provide description for screenreader users.' + } + ], + [ + { + text: 'name' + }, + { + text: 'string' + }, + { + text: 'Yes' + }, + { + text: 'The name of the textarea, which is submitted with the form data.' + } + ], + [ + { + text: 'rows' + }, + { + text: 'string' + }, + { + text: 'No' + }, + { + text: 'Optional number of textarea rows (default is 5 rows).' + } + ], + [ + { + text: 'value' + }, + { + text: 'string' + }, + { + text: 'No' + }, + { + text: 'Optional initial value of the textarea.' + } + ], + [ + { + text: 'label' + }, + { + text: 'object' + }, + { + text: 'Yes' + }, + { + text: 'Arguments for the label component. See label component.' + } + ], + [ + { + text: 'hint' + }, + { + text: 'object' + }, + { + text: 'No' + }, + { + text: 'Arguments for the hint component (e.g. text). See hint component.' + } + ], + [ + { + text: 'errorMessage' + }, + { + text: 'object' + }, + { + text: 'No' + }, + { + text: 'Arguments for the errorMessage component (e.g. text). See errorMessage component.' + } + ], + [ + { + text: 'classes' + }, + { + text: 'string' + }, + { + text: 'No' + }, + { + text: 'Optional additional classes to add to the textarea tag.' + } + ], + [ + { + text: 'attributes' + }, + { + text: 'object' + }, + { + text: 'No' + }, + { + text: 'Any extra HTML attributes (for example data attributes) to add to the textarea tag.' + } + ] + ] +}) }} +{% endblock %} diff --git a/src/components/character-count/__snapshots__/template.test.js.snap b/src/components/character-count/__snapshots__/template.test.js.snap new file mode 100644 index 0000000000..44c2e69cff --- /dev/null +++ b/src/components/character-count/__snapshots__/template.test.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Character count when it includes a hint renders with hint 1`] = ` + + + It’s on your National Insurance card, benefit letter, payslip or P60. For example, ‘QQ 12 34 56 C’. + + + You can enter up to characters + + +`; + +exports[`Character count when it includes an error message renders with error message 1`] = ` + + + Error message + + +`; + +exports[`Character count with dependant components renders with label 1`] = ` + + + +`; diff --git a/src/components/character-count/_character-count.scss b/src/components/character-count/_character-count.scss new file mode 100644 index 0000000000..b4e399daeb --- /dev/null +++ b/src/components/character-count/_character-count.scss @@ -0,0 +1,31 @@ +@import "../../settings/all"; +@import "../../tools/all"; +@import "../../helpers/all"; + +@import "../error-message/error-message"; +@import "../hint/hint"; +@import "../label/label"; + +@include govuk-exports("govuk/component/character-count") { + .govuk-character-count { + @include govuk-responsive-margin(6, "bottom"); + + .govuk-form-group, + .govuk-textarea { + margin-bottom: govuk-spacing(1); + } + + .govuk-textarea--error { + padding: govuk-spacing(1) - 2; // Stop a "jump" when width of border changes + } + } + + .govuk-character-count__message { + margin-top: 0; + margin-bottom: 0; + } + + .govuk-character-count__message--disabled { + visibility: hidden; + } +} diff --git a/src/components/character-count/character-count.js b/src/components/character-count/character-count.js new file mode 100644 index 0000000000..93ec55e620 --- /dev/null +++ b/src/components/character-count/character-count.js @@ -0,0 +1,187 @@ +import '../../vendor/polyfills/Function/prototype/bind' +import '../../vendor/polyfills/Event' // addEventListener and event.target normaliziation +import '../../vendor/polyfills/Element/prototype/classList' + +function CharacterCount ($module) { + this.$module = $module + this.$textarea = $module.querySelector('.js-character-count') +} + +CharacterCount.prototype.defaults = { + characterCountAttribute: 'data-maxlength', + wordCountAttribute: 'data-maxwords' +} + +// Initialize component +CharacterCount.prototype.init = function () { + // Check for module + var $module = this.$module + var $textarea = this.$textarea + if (!$textarea) { + return + } + + // Read options set using dataset ('data-' values) + this.options = this.getDataset($module) + + // Determine the limit attribute (characters or words) + var countAttribute = this.defaults.characterCountAttribute + if (this.options.maxwords) { + countAttribute = this.defaults.wordCountAttribute + } + + // Save the element limit + this.maxLength = $module.getAttribute(countAttribute) + + // Check for limit + if (!this.maxLength) { + return + } + + // Generate and reference message + var boundCreateCountMessage = this.createCountMessage.bind(this) + this.countMessage = boundCreateCountMessage() + + // If there's a maximum length defined and the count message exists + if (this.countMessage) { + // Remove hard limit if set + $module.removeAttribute('maxlength') + + // Bind event changes to the textarea + var boundChangeEvents = this.bindChangeEvents.bind(this) + boundChangeEvents() + + // Update count message + var boundUpdateCountMessage = this.updateCountMessage.bind(this) + boundUpdateCountMessage() + } +} + +// Read data attributes +CharacterCount.prototype.getDataset = function (element) { + var dataset = {} + var attributes = element.attributes + if (attributes) { + for (var i = 0; i < attributes.length; i++) { + var attribute = attributes[i] + var match = attribute.name.match(/^data-(.+)/) + if (match) { + dataset[match[1]] = attribute.value + } + } + } + return dataset +} + +// Counts characters or words in text +CharacterCount.prototype.count = function (text) { + var length + if (this.options.maxwords) { + var tokens = text.match(/\S+/g) || [] // Matches consecutive non-whitespace chars + length = tokens.length + } else { + length = text.length + } + return length +} + +// Generate count message and bind it to the input +// returns reference to the generated element +CharacterCount.prototype.createCountMessage = function () { + var countElement = this.$textarea + var elementId = countElement.id + // Check for existing info count message + var countMessage = document.getElementById(elementId + '-info') + // If there is no existing info count message we add one right after the field + if (elementId && !countMessage) { + countElement.insertAdjacentHTML('afterend', '') + this.describedBy = countElement.getAttribute('aria-describedby') + this.describedByInfo = this.describedBy + ' ' + elementId + '-info' + countElement.setAttribute('aria-describedby', this.describedByInfo) + countMessage = document.getElementById(elementId + '-info') + } else { + // If there is an existing info count message we move it right after the field + countElement.insertAdjacentElement('afterend', countMessage) + } + return countMessage +} + +// Bind input propertychange to the elements and update based on the change +CharacterCount.prototype.bindChangeEvents = function () { + var $textarea = this.$textarea + $textarea.addEventListener('keyup', this.checkIfValueChanged.bind(this)) + + // Bind focus/blur events to start/stop polling + $textarea.addEventListener('focus', this.handleFocus.bind(this)) + $textarea.addEventListener('blur', this.handleBlur.bind(this)) +} + +// Speech recognition software such as Dragon NaturallySpeaking will modify the +// fields by directly changing its `value`. These changes don't trigger events +// in JavaScript, so we need to poll to handle when and if they occur. +CharacterCount.prototype.checkIfValueChanged = function () { + if (!this.$textarea.oldValue) this.$textarea.oldValue = '' + if (this.$textarea.value !== this.$textarea.oldValue) { + this.$textarea.oldValue = this.$textarea.value + var boundUpdateCountMessage = this.updateCountMessage.bind(this) + boundUpdateCountMessage() + } +} + +// Update message box +CharacterCount.prototype.updateCountMessage = function () { + var countElement = this.$textarea + var options = this.options + var countMessage = this.countMessage + + // Determine the remaining number of characters/words + var currentLength = this.count(countElement.value) + var maxLength = this.maxLength + var remainingNumber = maxLength - currentLength + + // Set threshold if presented in options + var thresholdPercent = options.threshold ? options.threshold : 0 + var thresholdValue = maxLength * thresholdPercent / 100 + if (thresholdValue > currentLength) { + countMessage.classList.add('govuk-character-count__message--disabled') + } else { + countMessage.classList.remove('govuk-character-count__message--disabled') + } + + // Update styles + if (remainingNumber < 0) { + countElement.classList.add('govuk-textarea--error') + countMessage.classList.remove('govuk-hint') + countMessage.classList.add('govuk-error-message') + } else { + countElement.classList.remove('govuk-textarea--error') + countMessage.classList.remove('govuk-error-message') + countMessage.classList.add('govuk-hint') + } + + // Update message + var charVerb = 'remaining' + var charNoun = 'character' + var displayNumber = remainingNumber + if (options.maxwords) { + charNoun = 'word' + } + charNoun = charNoun + ((remainingNumber === -1 || remainingNumber === 1) ? '' : 's') + + charVerb = (remainingNumber < 0) ? 'too many' : 'remaining' + displayNumber = Math.abs(remainingNumber) + + countMessage.innerHTML = 'You have ' + displayNumber + ' ' + charNoun + ' ' + charVerb +} + +CharacterCount.prototype.handleFocus = function () { + // Check if value changed on focus + this.valueChecker = setInterval(this.checkIfValueChanged.bind(this), 1000) +} + +CharacterCount.prototype.handleBlur = function () { + // Cancel value checking on blur + clearInterval(this.valueChecker) +} + +export default CharacterCount diff --git a/src/components/character-count/character-count.yaml b/src/components/character-count/character-count.yaml new file mode 100644 index 0000000000..e7a71f61ed --- /dev/null +++ b/src/components/character-count/character-count.yaml @@ -0,0 +1,137 @@ +params: +- name: id + type: string + required: true + description: The id of the textarea. +- name: describedBy + type: string + required: false + description: Text or element id to add to the `aria-describedby` attribute to provide description for screenreader users. +- name: name + type: string + required: true + description: The name of the textarea, which is submitted with the form data. +- name: rows + type: string + required: false + description: Optional number of textarea rows (default is 5 rows). +- name: value + type: string + required: false + description: Optional initial value of the textarea. +- name: maxlength + type: string + required: true + description: If `maxwords` is set, this is not required. The maximum number of characters. If `maxwords` is provided, the `maxlength` argument will be ignored. +- name: maxwords + type: string + required: true + description: If `maxlength` is set, this is not required. The maximum number of words. If `maxwords` is provided, the `maxlength` argument will be ignored. +- name: threshold + type: string + required: true + description: 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. +- name: label + type: object + required: true + description: Options for the label component. + isComponent: true +- name: hint + type: object + required: false + description: Options for the hint component. + isComponent: true +- name: errorMessage + type: object + required: false + description: Options for the errorMessage component (e.g. text). + isComponent: true +- name: classes + type: string + required: false + description: Classes to add to the textarea. +- name: attributes + type: object + required: false + description: HTML attributes (for example data attributes) to add to the textarea. + +examples: + - name: default + data: + name: more-detail + id: more-detail + maxlength: 10 + label: + text: Can you provide more detail? + + - name: with hint + data: + name: with-hint + id: with-hint + maxlength: 10 + label: + text: Can you provide more detail? + hint: + text: Don't include personal or financial information, eg your + National Insurance number or credit card details. + + - name: with default value + data: + id: with-default-value + name: default-value + maxlength: 100 + label: + text: Full address + value: | + 221B Baker Street + London + NW1 6XE + + - name: with default value exceeding limit + data: + id: exceeding-characters + name: exceeding + maxlength: 10 + value: | + 221B Baker Street + London + NW1 6XE + label: + text: Full address + errorMessage: + text: Please do not exceed the maximum allowed limit + + - name: with custom rows + data: + id: custom-rows + name: custom + maxlength: 10 + label: + text: Full address + rows: 8 + + - name: with label as page heading + data: + id: textarea-with-page-heading + name: address + maxlength: 10 + label: + text: Full address + isPageHeading: true + + - name: with word count + data: + id: word-count + name: word-count + maxwords: 10 + label: + text: Full address + + - name: with threshold + data: + id: with-threshold + name: with-threshold + maxlength: 10 + threshold: 75 + label: + text: Full address diff --git a/src/components/character-count/macro.njk b/src/components/character-count/macro.njk new file mode 100644 index 0000000000..b8455729f7 --- /dev/null +++ b/src/components/character-count/macro.njk @@ -0,0 +1,3 @@ +{% macro govukCharacterCount(params) %} + {%- include "./template.njk" -%} +{% endmacro %} diff --git a/src/components/character-count/template.njk b/src/components/character-count/template.njk new file mode 100644 index 0000000000..7a3650a9cf --- /dev/null +++ b/src/components/character-count/template.njk @@ -0,0 +1,32 @@ +{% from "../error-message/macro.njk" import govukErrorMessage -%} +{% from "../hint/macro.njk" import govukHint %} +{% from "../label/macro.njk" import govukLabel %} +{% from "../textarea/macro.njk" import govukTextarea %} + +
+ {{ govukTextarea({ + id: params.id, + name: params.name, + describedBy: params.id + '-info', + rows: params.rows, + value: params.value, + classes: 'js-character-count ' + (' govuk-textarea--error' if params.errorMessage) + (params.classes if params.classes), + label: { + html: params.label.html, + text: params.label.text, + classes: params.label.classes, + isPageHeading: params.label.isPageHeading, + attributes: params.label.attributes, + for: params.id + }, + hint: params.hint, + errorMessage: params.errorMessage, + attributes: params.attributes + }) }} + + You can enter up to {{ params.maxlength or params.maxwords }} {{'words' if params.maxwords else 'characters' }} + +
diff --git a/src/components/character-count/template.test.js b/src/components/character-count/template.test.js new file mode 100644 index 0000000000..5f7a29d612 --- /dev/null +++ b/src/components/character-count/template.test.js @@ -0,0 +1,198 @@ +/* eslint-env jest */ + +const { axe } = require('jest-axe') + +const { render, getExamples, htmlWithClassName } = require('../../../lib/jest-helpers') + +const examples = getExamples('textarea') + +const WORD_BOUNDARY = '\\b' + +describe('Character count', () => { + describe('by default', () => { + it('passes accessibility tests', async () => { + const $ = render('character-count', examples.default) + + const results = await axe($.html()) + expect(results).toHaveNoViolations() + }) + + it('renders with classes', () => { + const $ = render('character-count', { + classes: 'app-character-count--custom-modifier' + }) + + const $component = $('.js-character-count') + expect($component.hasClass('app-character-count--custom-modifier')).toBeTruthy() + }) + + it('renders with id', () => { + const $ = render('character-count', { + id: 'my-character-count' + }) + + const $component = $('.js-character-count') + expect($component.attr('id')).toEqual('my-character-count') + }) + + it('renders with name', () => { + const $ = render('character-count', { + name: 'my-character-count-name' + }) + + const $component = $('.js-character-count') + expect($component.attr('name')).toEqual('my-character-count-name') + }) + + it('renders with rows', () => { + const $ = render('character-count', { + rows: '4' + }) + + const $component = $('.js-character-count') + expect($component.attr('rows')).toEqual('4') + }) + + it('renders with default number of rows', () => { + const $ = render('character-count', {}) + + const $component = $('.js-character-count') + expect($component.attr('rows')).toEqual('5') + }) + + it('renders with value', () => { + const $ = render('character-count', { + value: '221B Baker Street\nLondon\nNW1 6XE\n' + }) + + const $component = $('.js-character-count') + expect($component.text()).toEqual('221B Baker Street\nLondon\nNW1 6XE\n') + }) + + it('renders with attributes', () => { + const $ = render('character-count', { + attributes: { + 'data-attribute': 'my data value' + } + }) + + const $component = $('.js-character-count') + expect($component.attr('data-attribute')).toEqual('my data value') + }) + }) + + describe('when it includes a hint', () => { + it('renders with hint', () => { + const $ = render('character-count', { + id: 'character-count-with-hint', + hint: { + 'text': 'It’s on your National Insurance card, benefit letter, payslip or P60. For example, ‘QQ 12 34 56 C’.' + } + }) + + expect(htmlWithClassName($, '.govuk-hint')).toMatchSnapshot() + }) + + it('associates the character count as "described by" the hint', () => { + const $ = render('character-count', { + id: 'character-count-with-hint', + hint: { + 'text': 'It’s on your National Insurance card, benefit letter, payslip or P60. For example, ‘QQ 12 34 56 C’.' + } + }) + + const $textarea = $('.js-character-count') + const $hint = $('.govuk-hint') + + const hintId = new RegExp( + WORD_BOUNDARY + $hint.attr('id') + WORD_BOUNDARY + ) + + expect($textarea.attr('aria-describedby')) + .toMatch(hintId) + }) + }) + + describe('when it includes an error message', () => { + it('renders with error message', () => { + const $ = render('character-count', { + id: 'character-count-with-error', + errorMessage: { + text: 'Error message' + } + }) + + expect(htmlWithClassName($, '.govuk-error-message')).toMatchSnapshot() + }) + + it('associates the character-count as "described by" the error message', () => { + const $ = render('character-count', { + id: 'character-count-with-error', + errorMessage: { + 'text': 'Error message' + } + }) + + const $component = $('.js-character-count') + const $errorMessage = $('.govuk-error-message') + + const errorMessageId = new RegExp( + WORD_BOUNDARY + $errorMessage.attr('id') + WORD_BOUNDARY + ) + + expect($component.attr('aria-describedby')) + .toMatch(errorMessageId) + }) + + it('adds the error class to the character-count', () => { + const $ = render('character-count', { + errorMessage: { + 'text': 'Error message' + } + }) + + const $component = $('.js-character-count') + expect($component.hasClass('govuk-textarea--error')).toBeTruthy() + }) + }) + + describe('with dependant components', () => { + it('have correct nesting order', () => { + const $ = render('character-count', { + id: 'nested-order', + label: { + 'text': 'Full address' + }, + errorMessage: { + 'text': 'Error message' + } + }) + + const $component = $('.govuk-form-group > .js-character-count') + expect($component.length).toBeTruthy() + }) + + it('renders with label', () => { + const $ = render('character-count', { + id: 'my-character-count', + label: { + 'text': 'Full address' + } + }) + + expect(htmlWithClassName($, '.govuk-label')).toMatchSnapshot() + }) + + it('renders label with "for" attribute reffering the character count "id"', () => { + const $ = render('character-count', { + id: 'my-character-count', + label: { + 'text': 'Full address' + } + }) + + const $label = $('.govuk-label') + expect($label.attr('for')).toEqual('my-character-count') + }) + }) +})