diff --git a/CHANGELOG.md b/CHANGELOG.md index 248aba9091..7cfe6fb104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,29 @@ ## Unreleased +### Recommended changes + +We've recently made some non-breaking changes to GOV.UK Frontend. Implementing these changes will make your service work better. + +#### Remove `aria-live` from the character count component + +If you're not using the Nunjucks macros, remove the `aria-live` attribute from the character count message element. This element's content no longer updates, as we've moved the live counter functionality to a new element injected by JavaScript. + +This change was introduced in [pull request #2577: Refactor character count to inject new element](https://github.com/alphagov/govuk-frontend/pull/2577) + ### Fixes +We've made the following fixes in [pull request #2577: Refactor character count to inject new element](https://github.com/alphagov/govuk-frontend/pull/2577): + +- fix character count message being repeated twice by screen readers +- fix character count hint text being announced as part of the count message +- fix multiple outdated character count messages being announced at once +- fix character count message being announced when input length is below a defined threshold + +We’ve also made fixes in the following pull requests: + - [#2549: Fix header with product name focus and hover state length](https://github.com/alphagov/govuk-frontend/pull/2549) -- [#2573: Better handle cases where govuk-text-colour is set to a non-colour value](https://github.com/alphagov/govuk-frontend/pull/2573) +- [#2573: Better handle cases where `$govuk-text-colour` is set to a non-colour value](https://github.com/alphagov/govuk-frontend/pull/2573) ## 4.0.1 (Fix release) diff --git a/src/govuk/components/character-count/__snapshots__/template.test.js.snap b/src/govuk/components/character-count/__snapshots__/template.test.js.snap index 44c23cb9fb..ff8586bdd0 100644 --- a/src/govuk/components/character-count/__snapshots__/template.test.js.snap +++ b/src/govuk/components/character-count/__snapshots__/template.test.js.snap @@ -8,7 +8,6 @@ exports[`Character count when it includes a hint renders with hint 1`] = `
You can enter up to 10 characters
diff --git a/src/govuk/components/character-count/character-count.js b/src/govuk/components/character-count/character-count.js index 9922c815ae..fcc5a10f92 100644 --- a/src/govuk/components/character-count/character-count.js +++ b/src/govuk/components/character-count/character-count.js @@ -5,9 +5,9 @@ import '../../vendor/polyfills/Element/prototype/classList' function CharacterCount ($module) { this.$module = $module this.$textarea = $module.querySelector('.govuk-js-character-count') - if (this.$textarea) { - this.$countMessage = document.getElementById(this.$textarea.id + '-info') - } + this.$visibleCountMessage = null + this.$screenReaderCountMessage = null + this.lastInputTimestamp = null } CharacterCount.prototype.defaults = { @@ -17,18 +17,39 @@ CharacterCount.prototype.defaults = { // Initialize component CharacterCount.prototype.init = function () { + // Check that required elements are present + if (!this.$textarea) { + return + } + // Check for module var $module = this.$module var $textarea = this.$textarea - var $countMessage = this.$countMessage - - if (!$textarea || !$countMessage) { - return - } + var $fallbackLimitMessage = document.getElementById($textarea.id + '-info') - // We move count message right after the field + // Move the fallback count message to be immediately after the textarea // Kept for backwards compatibility - $textarea.insertAdjacentElement('afterend', $countMessage) + $textarea.insertAdjacentElement('afterend', $fallbackLimitMessage) + + // Create the *screen reader* specific live-updating counter + // This doesn't need any styling classes, as it is never visible + var $screenReaderCountMessage = document.createElement('div') + $screenReaderCountMessage.className = 'govuk-character-count__sr-status govuk-visually-hidden' + $screenReaderCountMessage.setAttribute('aria-live', 'polite') + this.$screenReaderCountMessage = $screenReaderCountMessage + $fallbackLimitMessage.insertAdjacentElement('afterend', $screenReaderCountMessage) + + // Create our live-updating counter element, copying the classes from the + // fallback element for backwards compatibility as these may have been configured + var $visibleCountMessage = document.createElement('div') + $visibleCountMessage.className = $fallbackLimitMessage.className + $visibleCountMessage.classList.add('govuk-character-count__status') + $visibleCountMessage.setAttribute('aria-hidden', 'true') + this.$visibleCountMessage = $visibleCountMessage + $fallbackLimitMessage.insertAdjacentElement('afterend', $visibleCountMessage) + + // Hide the fallback limit message + $fallbackLimitMessage.classList.add('govuk-visually-hidden') // Read options set using dataset ('data-' values) this.options = this.getDataset($module) @@ -50,21 +71,17 @@ CharacterCount.prototype.init = function () { // Remove hard limit if set $module.removeAttribute('maxlength') + this.bindChangeEvents() + // When the page is restored after navigating 'back' in some browsers the // state of the character count is not restored until *after* the DOMContentLoaded - // event is fired, so we need to sync after the pageshow event in browsers - // that support it. + // event is fired, so we need to manually update it after the pageshow event + // in browsers that support it. if ('onpageshow' in window) { - window.addEventListener('pageshow', this.sync.bind(this)) + window.addEventListener('pageshow', this.updateCountMessage.bind(this)) } else { - window.addEventListener('DOMContentLoaded', this.sync.bind(this)) + window.addEventListener('DOMContentLoaded', this.updateCountMessage.bind(this)) } - - this.sync() -} - -CharacterCount.prototype.sync = function () { - this.bindChangeEvents() this.updateCountMessage() } @@ -99,7 +116,7 @@ CharacterCount.prototype.count = function (text) { // 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)) + $textarea.addEventListener('keyup', this.handleKeyUp.bind(this)) // Bind focus/blur events to start/stop polling $textarea.addEventListener('focus', this.handleFocus.bind(this)) @@ -117,42 +134,64 @@ CharacterCount.prototype.checkIfValueChanged = function () { } } -// Update message box +// Helper function to update both the visible and screen reader-specific +// counters simultaneously (e.g. on init) CharacterCount.prototype.updateCountMessage = function () { - var countElement = this.$textarea - var options = this.options - var countMessage = this.$countMessage + this.updateVisibleCountMessage() + this.updateScreenReaderCountMessage() +} - // Determine the remaining number of characters/words - var currentLength = this.count(countElement.value) - var maxLength = this.maxLength - var remainingNumber = maxLength - currentLength +// Update visible counter +CharacterCount.prototype.updateVisibleCountMessage = function () { + var $textarea = this.$textarea + var $visibleCountMessage = this.$visibleCountMessage + var remainingNumber = this.maxLength - this.count($textarea.value) - // 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') - // Ensure threshold is hidden for users of assistive technologies - countMessage.setAttribute('aria-hidden', true) + // If input is over the threshold, remove the disabled class which renders the + // counter invisible. + if (this.isOverThreshold()) { + $visibleCountMessage.classList.remove('govuk-character-count__message--disabled') } else { - countMessage.classList.remove('govuk-character-count__message--disabled') - // Ensure threshold is visible for users of assistive technologies - countMessage.removeAttribute('aria-hidden') + $visibleCountMessage.classList.add('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') + $textarea.classList.add('govuk-textarea--error') + $visibleCountMessage.classList.remove('govuk-hint') + $visibleCountMessage.classList.add('govuk-error-message') + } else { + $textarea.classList.remove('govuk-textarea--error') + $visibleCountMessage.classList.remove('govuk-error-message') + $visibleCountMessage.classList.add('govuk-hint') + } + + // Update message + $visibleCountMessage.innerHTML = this.formattedUpdateMessage() +} + +// Update screen reader-specific counter +CharacterCount.prototype.updateScreenReaderCountMessage = function () { + var $screenReaderCountMessage = this.$screenReaderCountMessage + + // If over the threshold, remove the aria-hidden attribute, allowing screen + // readers to announce the content of the element. + if (this.isOverThreshold()) { + $screenReaderCountMessage.removeAttribute('aria-hidden') } else { - countElement.classList.remove('govuk-textarea--error') - countMessage.classList.remove('govuk-error-message') - countMessage.classList.add('govuk-hint') + $screenReaderCountMessage.setAttribute('aria-hidden', true) } // Update message + $screenReaderCountMessage.innerHTML = this.formattedUpdateMessage() +} + +// Format update message +CharacterCount.prototype.formattedUpdateMessage = function () { + var $textarea = this.$textarea + var options = this.options + var remainingNumber = this.maxLength - this.count($textarea.value) + var charVerb = 'remaining' var charNoun = 'character' var displayNumber = remainingNumber @@ -164,12 +203,44 @@ CharacterCount.prototype.updateCountMessage = function () { charVerb = (remainingNumber < 0) ? 'too many' : 'remaining' displayNumber = Math.abs(remainingNumber) - countMessage.innerHTML = 'You have ' + displayNumber + ' ' + charNoun + ' ' + charVerb + return 'You have ' + displayNumber + ' ' + charNoun + ' ' + charVerb +} + +// Checks whether the value is over the configured threshold for the input. +// If there is no configured threshold, it is set to 0 and this function will +// always return true. +CharacterCount.prototype.isOverThreshold = function () { + var $textarea = this.$textarea + var options = this.options + + // Determine the remaining number of characters/words + var currentLength = this.count($textarea.value) + var maxLength = this.maxLength + + // Set threshold if presented in options + var thresholdPercent = options.threshold ? options.threshold : 0 + var thresholdValue = maxLength * thresholdPercent / 100 + + return (thresholdValue <= currentLength) +} + +// Update the visible character counter and keep track of when the last update +// happened for each keypress +CharacterCount.prototype.handleKeyUp = function () { + this.updateVisibleCountMessage() + this.lastInputTimestamp = Date.now() } CharacterCount.prototype.handleFocus = function () { - // Check if value changed on focus - this.valueChecker = setInterval(this.checkIfValueChanged.bind(this), 1000) + // If the field is focused, and a keyup event hasn't been detected for at + // least 1000 ms (1 second), then run the manual change check. + // This is so that the update triggered by the manual comparison doesn't + // conflict with debounced KeyboardEvent updates. + this.valueChecker = setInterval(function () { + if (!this.lastInputTimestamp || (Date.now() - 500) >= this.lastInputTimestamp) { + this.checkIfValueChanged() + } + }.bind(this), 1000) } CharacterCount.prototype.handleBlur = function () { diff --git a/src/govuk/components/character-count/character-count.test.js b/src/govuk/components/character-count/character-count.test.js index 765e010299..ce3498d261 100644 --- a/src/govuk/components/character-count/character-count.test.js +++ b/src/govuk/components/character-count/character-count.test.js @@ -4,6 +4,10 @@ const configPaths = require('../../../../config/paths.json') const PORT = configPaths.ports.test const baseUrl = `http://localhost:${PORT}` +// The longest possible time from a keyboard user ending input and the screen +// reader counter being updated: handleFocus interval time + last input wait time +const debouncedWaitTime = 1500 + const goToExample = (exampleName = false) => { const url = exampleName ? `${baseUrl}/components/character-count/${exampleName}/preview` @@ -31,39 +35,74 @@ describe('Character count', () => { }) describe('when JavaScript is available', () => { + describe('on page load', () => { + beforeAll(async () => { + await goToExample() + }) + + it('injects the visual counter', async () => { + const message = await page.$('.govuk-character-count__status') !== null + expect(message).toBeTruthy() + }) + + it('injects the screen reader counter', async () => { + const srMessage = await page.$('.govuk-character-count__sr-status') !== null + expect(srMessage).toBeTruthy() + }) + + it('hides the fallback hint', async () => { + const messageClasses = await page.$eval('.govuk-character-count__message', el => el.className) + expect(messageClasses).toContain('govuk-visually-hidden') + }) + }) + describe('when counting characters', () => { it('shows the dynamic message', async () => { await goToExample() - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) - + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 10 characters remaining') + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 10 characters remaining') }) it('shows the characters remaining if the field is pre-filled', async () => { await goToExample('with-default-value') - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) - + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 67 characters remaining') + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 67 characters remaining') }) it('counts down to the character limit', async () => { await goToExample() await page.type('.govuk-js-character-count', 'A') - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) - + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 9 characters remaining') + + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 9 characters remaining') }) it('uses the singular when there is only one character remaining', async () => { await goToExample() await page.type('.govuk-js-character-count', 'A'.repeat(9)) - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) - + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 1 character remaining') + + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 1 character remaining') }) describe('when the character limit is exceeded', () => { @@ -73,15 +112,27 @@ describe('Character count', () => { }) it('shows the number of characters over the limit', async () => { - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 1 character too many') + + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 1 character too many') }) it('uses the plural when the limit is exceeded by 2 or more', async () => { await page.type('.govuk-js-character-count', 'A') - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 2 characters too many') + + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 2 characters too many') }) it('adds error styles to the textarea', async () => { @@ -90,7 +141,7 @@ describe('Character count', () => { }) it('adds error styles to the count message', async () => { - const messageClasses = await page.$eval('.govuk-character-count__message', el => el.className) + const messageClasses = await page.$eval('.govuk-character-count__status', el => el.className) expect(messageClasses).toContain('govuk-error-message') }) }) @@ -101,8 +152,11 @@ describe('Character count', () => { }) it('shows the number of characters over the limit', async () => { - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 23 characters too many') + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 23 characters too many') }) it('adds error styles to the textarea', async () => { @@ -111,7 +165,7 @@ describe('Character count', () => { }) it('adds error styles to the count message', async () => { - const messageClasses = await page.$eval('.govuk-character-count__message', el => el.className) + const messageClasses = await page.$eval('.govuk-character-count__status', el => el.className) expect(messageClasses).toContain('govuk-error-message') }) }) @@ -122,22 +176,28 @@ describe('Character count', () => { }) it('does not show the limit until the threshold is reached', async () => { - const visibility = await page.$eval('.govuk-character-count__message', el => window.getComputedStyle(el).visibility) + const visibility = await page.$eval('.govuk-character-count__status', el => window.getComputedStyle(el).visibility) expect(visibility).toEqual('hidden') + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) + // Ensure threshold is hidden for users of assistive technologies - const ariaHidden = await page.$eval('.govuk-character-count__message', el => el.getAttribute('aria-hidden')) + const ariaHidden = await page.$eval('.govuk-character-count__sr-status', el => el.getAttribute('aria-hidden')) expect(ariaHidden).toEqual('true') }) it('becomes visible once the threshold is reached', async () => { await page.type('.govuk-js-character-count', 'A'.repeat(8)) - const visibility = await page.$eval('.govuk-character-count__message', el => window.getComputedStyle(el).visibility) + const visibility = await page.$eval('.govuk-character-count__status', el => window.getComputedStyle(el).visibility) expect(visibility).toEqual('visible') + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) + // Ensure threshold is visible for users of assistive technologies - const ariaHidden = await page.$eval('.govuk-character-count__message', el => el.getAttribute('aria-hidden')) + const ariaHidden = await page.$eval('.govuk-character-count__sr-status', el => el.getAttribute('aria-hidden')) expect(ariaHidden).toBeFalsy() }) }) @@ -147,9 +207,11 @@ describe('Character count', () => { it('still works correctly', async () => { await goToExample('with-id-starting-with-number') - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) - + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 10 characters remaining') + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 10 characters remaining') }) }) @@ -157,9 +219,11 @@ describe('Character count', () => { it('still works correctly', async () => { await goToExample('with-id-with-special-characters') - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) - + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 10 characters remaining') + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 10 characters remaining') }) }) }) @@ -168,27 +232,39 @@ describe('Character count', () => { it('shows the dynamic message', async () => { await goToExample('with-word-count') - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) - + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 10 words remaining') + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 10 words remaining') }) it('counts down to the word limit', async () => { await goToExample('with-word-count') await page.type('.govuk-js-character-count', 'Hello world') - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) - + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 8 words remaining') + + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 8 words remaining') }) it('uses the singular when there is only one word remaining', async () => { await goToExample('with-word-count') await page.type('.govuk-js-character-count', 'Hello '.repeat(9)) - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) - + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 1 word remaining') + + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 1 word remaining') }) describe('when the word limit is exceeded', () => { @@ -198,15 +274,27 @@ describe('Character count', () => { }) it('shows the number of words over the limit', async () => { - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 1 word too many') + + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 1 word too many') }) it('uses the plural when the limit is exceeded by 2 or more', async () => { await page.type('.govuk-js-character-count', 'World') - const message = await page.$eval('.govuk-character-count__message', el => el.innerHTML.trim()) + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 2 words too many') + + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) + + const srMessage = await page.$eval('.govuk-character-count__sr-status', el => el.innerHTML.trim()) + expect(srMessage).toEqual('You have 2 words too many') }) it('adds error styles to the textarea', async () => { @@ -215,7 +303,7 @@ describe('Character count', () => { }) it('adds error styles to the count message', async () => { - const messageClasses = await page.$eval('.govuk-character-count__message', el => el.className) + const messageClasses = await page.$eval('.govuk-character-count__status', el => el.className) expect(messageClasses).toContain('govuk-error-message') }) }) diff --git a/src/govuk/components/character-count/template.njk b/src/govuk/components/character-count/template.njk index d1eb46b1e7..297604176a 100644 --- a/src/govuk/components/character-count/template.njk +++ b/src/govuk/components/character-count/template.njk @@ -29,9 +29,6 @@ {{ govukHint({ text: 'You can enter up to ' + (params.maxlength or params.maxwords) + (' words' if params.maxwords else ' characters'), id: params.id + '-info', - classes: 'govuk-character-count__message' + (' ' + params.countMessage.classes if params.countMessage.classes), - attributes: { - 'aria-live': 'polite' - } + classes: 'govuk-character-count__message' + (' ' + params.countMessage.classes if params.countMessage.classes) }) }} diff --git a/src/govuk/components/character-count/template.test.js b/src/govuk/components/character-count/template.test.js index 32d9640408..bed9028feb 100644 --- a/src/govuk/components/character-count/template.test.js +++ b/src/govuk/components/character-count/template.test.js @@ -114,13 +114,6 @@ describe('Character count', () => { const $countMessage = $('.govuk-character-count__message') expect($countMessage.hasClass('app-custom-count-message')).toBeTruthy() }) - - it('renders with aria live set to polite', () => { - const $ = render('character-count', examples.default) - - const $countMessage = $('.govuk-character-count__message') - expect($countMessage.attr('aria-live')).toEqual('polite') - }) }) describe('when it has the spellcheck attribute', () => {