From 7c2ce9dc11f8a6c862585d1610785088b31b4b66 Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Mon, 21 Mar 2022 15:54:21 +0000 Subject: [PATCH 01/12] Refactor character count to inject new element Changes the character count to inject a new element that is live updated, instead of overwriting the content of the initial HTML hint. This is intended to resolve an issue where screen readers are reading out all elements referenced in aria-describedby for each keystroke, and not just the updated counter element. https://github.com/alphagov/govuk-frontend/issues/2539 --- .../__snapshots__/template.test.js.snap | 1 - .../character-count/character-count.js | 22 +++++++--- .../character-count/character-count.test.js | 42 +++++++++---------- .../components/character-count/template.njk | 5 +-- .../character-count/template.test.js | 7 ---- 5 files changed, 39 insertions(+), 38 deletions(-) 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..386e985a65 100644 --- a/src/govuk/components/character-count/character-count.js +++ b/src/govuk/components/character-count/character-count.js @@ -6,7 +6,7 @@ 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.$fallbackLimitMessage = document.getElementById(this.$textarea.id + '-info') } } @@ -20,15 +20,27 @@ CharacterCount.prototype.init = function () { // Check for module var $module = this.$module var $textarea = this.$textarea - var $countMessage = this.$countMessage + var $fallbackLimitMessage = this.$fallbackLimitMessage - if (!$textarea || !$countMessage) { + if (!$textarea) { return } - // We move count message right after the field + // We move fallback count message right after the field // Kept for backwards compatibility - $textarea.insertAdjacentElement('afterend', $countMessage) + $textarea.insertAdjacentElement('afterend', $fallbackLimitMessage) + + // Create our live-updating counter element, copying the classes from the + // fallback element for backwards compatibility as these may have been configured + var $countMessage = document.createElement('div') + $countMessage.className = $fallbackLimitMessage.className + $countMessage.classList.add('govuk-character-count__status') + $countMessage.setAttribute('aria-live', 'polite') + this.$countMessage = $countMessage + $fallbackLimitMessage.insertAdjacentElement('afterend', $countMessage) + + // Hide the fallback limit message + $fallbackLimitMessage.classList.add('govuk-visually-hidden') // Read options set using dataset ('data-' values) this.options = this.getDataset($module) diff --git a/src/govuk/components/character-count/character-count.test.js b/src/govuk/components/character-count/character-count.test.js index 765e010299..771074ef1d 100644 --- a/src/govuk/components/character-count/character-count.test.js +++ b/src/govuk/components/character-count/character-count.test.js @@ -35,7 +35,7 @@ describe('Character count', () => { 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') }) @@ -43,7 +43,7 @@ describe('Character count', () => { 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') }) @@ -52,7 +52,7 @@ describe('Character count', () => { 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') }) @@ -61,7 +61,7 @@ describe('Character count', () => { 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') }) @@ -73,14 +73,14 @@ 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') }) 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') }) @@ -90,7 +90,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,7 +101,7 @@ 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') }) @@ -111,7 +111,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 +122,22 @@ 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') // 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__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') // 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__status', el => el.getAttribute('aria-hidden')) expect(ariaHidden).toBeFalsy() }) }) @@ -147,7 +147,7 @@ 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') }) @@ -157,7 +157,7 @@ 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') }) @@ -168,7 +168,7 @@ 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') }) @@ -177,7 +177,7 @@ describe('Character count', () => { 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') }) @@ -186,7 +186,7 @@ describe('Character count', () => { 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') }) @@ -198,14 +198,14 @@ 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') }) 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') }) @@ -215,7 +215,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', () => { From 0ced84a1260fbe82730a1ad55ab3493fafa26d3e Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Tue, 22 Mar 2022 15:05:19 +0000 Subject: [PATCH 02/12] Refactor out redundant character counter code --- src/govuk/components/character-count/character-count.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/govuk/components/character-count/character-count.js b/src/govuk/components/character-count/character-count.js index 386e985a65..6abe7e0ba1 100644 --- a/src/govuk/components/character-count/character-count.js +++ b/src/govuk/components/character-count/character-count.js @@ -5,9 +5,7 @@ import '../../vendor/polyfills/Element/prototype/classList' function CharacterCount ($module) { this.$module = $module this.$textarea = $module.querySelector('.govuk-js-character-count') - if (this.$textarea) { - this.$fallbackLimitMessage = document.getElementById(this.$textarea.id + '-info') - } + this.$countMessage = null } CharacterCount.prototype.defaults = { @@ -20,7 +18,7 @@ CharacterCount.prototype.init = function () { // Check for module var $module = this.$module var $textarea = this.$textarea - var $fallbackLimitMessage = this.$fallbackLimitMessage + var $fallbackLimitMessage = document.getElementById(this.$textarea.id + '-info') if (!$textarea) { return From 8d06ceef8286ae16f8cd7e4655ddbd06cbeba73d Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Thu, 31 Mar 2022 15:44:21 +0100 Subject: [PATCH 03/12] Debounce character counter update Debounces the character counter update until 250 milliseconds after the user has stopped typing. This helps prevent multiple rapid-fire updates being queued up by screen readers and read out afterwards, and prevents "stuttering" by screen readers which attempt to read out the updated counter and the user's input simultaneously. The handleFocus method's bugfix for Dragon Naturally Speaking now runs a check to see when the last user input was provided, and will not update the counter if the user has recently typed anything. This prevents the DNS fix from causing the same queuing and stuttering behaviour. This also fixes a newly identified bug where the keyup, focus and blur event listeners were all being bound twice due to the sync method being called both on script initialisation and on pageshow/DOMContentLoaded events. The sync method has been removed and this now calls updateCountMessage directly, which is the part that actually requires syncronisation. --- .../character-count/character-count.js | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/govuk/components/character-count/character-count.js b/src/govuk/components/character-count/character-count.js index 6abe7e0ba1..957d3e5df2 100644 --- a/src/govuk/components/character-count/character-count.js +++ b/src/govuk/components/character-count/character-count.js @@ -6,6 +6,8 @@ function CharacterCount ($module) { this.$module = $module this.$textarea = $module.querySelector('.govuk-js-character-count') this.$countMessage = null + this.lastInputTimestamp = null + this.debouncedInputTimer = null } CharacterCount.prototype.defaults = { @@ -60,21 +62,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() } @@ -109,7 +107,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)) @@ -177,9 +175,25 @@ CharacterCount.prototype.updateCountMessage = function () { countMessage.innerHTML = 'You have ' + displayNumber + ' ' + charNoun + ' ' + charVerb } +// Debounce updating the character counter until after a user has stopped typing +// for a short period of time. This helps prevent screen readers from queuing up +// multiple text updates in rapid succession. +CharacterCount.prototype.handleKeyUp = function () { + this.lastInputTimestamp = Date.now() + clearTimeout(this.debouncedInputTimer) + this.debouncedInputTimer = setTimeout(this.updateCountMessage.bind(this), 250) +} + 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() - 1000) >= this.lastInputTimestamp) { + this.checkIfValueChanged.bind(this) + } + }.bind(this), 1000) } CharacterCount.prototype.handleBlur = function () { From c8f3fe2038508131eddf2972d59e7f2d6e034753 Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Fri, 1 Apr 2022 13:49:46 +0100 Subject: [PATCH 04/12] Add wait time to tests Adds wait time to tests that involve changing the value of the character counter's textarea, so that the 250ms debounce timer can complete the update before the result is tested for. --- .../character-count/character-count.test.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/govuk/components/character-count/character-count.test.js b/src/govuk/components/character-count/character-count.test.js index 771074ef1d..17dfad4527 100644 --- a/src/govuk/components/character-count/character-count.test.js +++ b/src/govuk/components/character-count/character-count.test.js @@ -52,6 +52,9 @@ describe('Character count', () => { await goToExample() await page.type('.govuk-js-character-count', 'A') + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, 300)) + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 9 characters remaining') @@ -61,6 +64,9 @@ describe('Character count', () => { await goToExample() await page.type('.govuk-js-character-count', 'A'.repeat(9)) + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, 300)) + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 1 character remaining') @@ -70,6 +76,9 @@ describe('Character count', () => { beforeAll(async () => { await goToExample() await page.type('.govuk-js-character-count', 'A'.repeat(11)) + + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, 300)) }) it('shows the number of characters over the limit', async () => { @@ -80,6 +89,9 @@ describe('Character count', () => { it('uses the plural when the limit is exceeded by 2 or more', async () => { await page.type('.govuk-js-character-count', 'A') + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, 300)) + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 2 characters too many') }) @@ -133,6 +145,9 @@ describe('Character count', () => { it('becomes visible once the threshold is reached', async () => { await page.type('.govuk-js-character-count', 'A'.repeat(8)) + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, 300)) + const visibility = await page.$eval('.govuk-character-count__status', el => window.getComputedStyle(el).visibility) expect(visibility).toEqual('visible') @@ -177,6 +192,9 @@ describe('Character count', () => { await goToExample('with-word-count') await page.type('.govuk-js-character-count', 'Hello world') + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, 300)) + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 8 words remaining') @@ -186,6 +204,9 @@ describe('Character count', () => { await goToExample('with-word-count') await page.type('.govuk-js-character-count', 'Hello '.repeat(9)) + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, 300)) + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 1 word remaining') @@ -195,6 +216,9 @@ describe('Character count', () => { beforeAll(async () => { await goToExample('with-word-count') await page.type('.govuk-js-character-count', 'Hello '.repeat(11)) + + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, 300)) }) it('shows the number of words over the limit', async () => { @@ -205,6 +229,9 @@ describe('Character count', () => { it('uses the plural when the limit is exceeded by 2 or more', async () => { await page.type('.govuk-js-character-count', 'World') + // Wait for debounced update to happen + await new Promise((resolve) => setTimeout(resolve, 300)) + const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 2 words too many') }) From 9a90f01164352f8218b635e24a0362a05405847d Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Thu, 7 Apr 2022 14:43:37 +0100 Subject: [PATCH 05/12] Modify character counter to have screen reader-specific counter Splits the character counter into two: - A visible counter, that updates immediately, and is hidden to screen readers. - An screen reader counter, that updates only after the user has stopped typing for one second, and is visually hidden. --- .../character-count/character-count.js | 115 +++++++++++++----- .../character-count/character-count.test.js | 37 ++---- 2 files changed, 92 insertions(+), 60 deletions(-) diff --git a/src/govuk/components/character-count/character-count.js b/src/govuk/components/character-count/character-count.js index 957d3e5df2..97d936b78c 100644 --- a/src/govuk/components/character-count/character-count.js +++ b/src/govuk/components/character-count/character-count.js @@ -5,7 +5,8 @@ import '../../vendor/polyfills/Element/prototype/classList' function CharacterCount ($module) { this.$module = $module this.$textarea = $module.querySelector('.govuk-js-character-count') - this.$countMessage = null + this.$visibleCountMessage = null + this.$screenReaderCountMessage = null this.lastInputTimestamp = null this.debouncedInputTimer = null } @@ -30,14 +31,22 @@ CharacterCount.prototype.init = function () { // Kept for backwards compatibility $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 $countMessage = document.createElement('div') - $countMessage.className = $fallbackLimitMessage.className - $countMessage.classList.add('govuk-character-count__status') - $countMessage.setAttribute('aria-live', 'polite') - this.$countMessage = $countMessage - $fallbackLimitMessage.insertAdjacentElement('afterend', $countMessage) + 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') @@ -125,28 +134,25 @@ 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 countElement = this.$textarea + var countMessage = this.$visibleCountMessage + var remainingNumber = this.maxLength - this.count(countElement.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) - } else { + // If input is over the threshold, remove the disabled class which renders the + // counter invisible. + if (this.isOverThreshold()) { countMessage.classList.remove('govuk-character-count__message--disabled') - // Ensure threshold is visible for users of assistive technologies - countMessage.removeAttribute('aria-hidden') + } else { + countMessage.classList.add('govuk-character-count__message--disabled') } // Update styles @@ -161,6 +167,31 @@ CharacterCount.prototype.updateCountMessage = function () { } // Update message + countMessage.innerHTML = this.formatUpdateMessage() +} + +// Update screen reader-specific counter +CharacterCount.prototype.updateScreenReaderCountMessage = function () { + var countMessage = this.$screenReaderCountMessage + + // If other the threshold, remove the aria-hidden attribute, allowing screen + // readers to announce the content of the element. + if (this.isOverThreshold()) { + countMessage.removeAttribute('aria-hidden') + } else { + countMessage.setAttribute('aria-hidden', true) + } + + // Update message + countMessage.innerHTML = this.formatUpdateMessage() +} + +// Format update message +CharacterCount.prototype.formatUpdateMessage = function () { + var countElement = this.$textarea + var options = this.options + var remainingNumber = this.maxLength - this.count(countElement.value) + var charVerb = 'remaining' var charNoun = 'character' var displayNumber = remainingNumber @@ -172,16 +203,38 @@ 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 countElement = this.$textarea + var options = this.options + + // Determine the remaining number of characters/words + var currentLength = this.count(countElement.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) } -// Debounce updating the character counter until after a user has stopped typing -// for a short period of time. This helps prevent screen readers from queuing up -// multiple text updates in rapid succession. +// Handle the user typing. +// Debounces updating the screen reader counter until after a user has stopped +// typing for a short period of time. This helps prevent screen readers from +// queuing up multiple text updates in rapid succession. CharacterCount.prototype.handleKeyUp = function () { - this.lastInputTimestamp = Date.now() + // Update the visible character counter and keep track of when the update happened + this.updateVisibleCountMessage() + + // Clear the previous timeout, as an update has just taken place, and set a new one clearTimeout(this.debouncedInputTimer) - this.debouncedInputTimer = setTimeout(this.updateCountMessage.bind(this), 250) + this.debouncedInputTimer = setTimeout(this.updateScreenReaderCountMessage.bind(this), 500) } CharacterCount.prototype.handleFocus = function () { diff --git a/src/govuk/components/character-count/character-count.test.js b/src/govuk/components/character-count/character-count.test.js index 17dfad4527..9be7b8b60c 100644 --- a/src/govuk/components/character-count/character-count.test.js +++ b/src/govuk/components/character-count/character-count.test.js @@ -52,9 +52,6 @@ describe('Character count', () => { await goToExample() await page.type('.govuk-js-character-count', 'A') - // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 300)) - const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 9 characters remaining') @@ -64,9 +61,6 @@ describe('Character count', () => { await goToExample() await page.type('.govuk-js-character-count', 'A'.repeat(9)) - // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 300)) - const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 1 character remaining') @@ -76,9 +70,6 @@ describe('Character count', () => { beforeAll(async () => { await goToExample() await page.type('.govuk-js-character-count', 'A'.repeat(11)) - - // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 300)) }) it('shows the number of characters over the limit', async () => { @@ -89,9 +80,6 @@ describe('Character count', () => { it('uses the plural when the limit is exceeded by 2 or more', async () => { await page.type('.govuk-js-character-count', 'A') - // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 300)) - const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 2 characters too many') }) @@ -137,22 +125,25 @@ describe('Character count', () => { 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, 1100)) + // Ensure threshold is hidden for users of assistive technologies - const ariaHidden = await page.$eval('.govuk-character-count__status', 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)) - // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 300)) - 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, 1100)) + // Ensure threshold is visible for users of assistive technologies - const ariaHidden = await page.$eval('.govuk-character-count__status', el => el.getAttribute('aria-hidden')) + const ariaHidden = await page.$eval('.govuk-character-count__sr-status', el => el.getAttribute('aria-hidden')) expect(ariaHidden).toBeFalsy() }) }) @@ -192,9 +183,6 @@ describe('Character count', () => { await goToExample('with-word-count') await page.type('.govuk-js-character-count', 'Hello world') - // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 300)) - const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 8 words remaining') @@ -204,9 +192,6 @@ describe('Character count', () => { await goToExample('with-word-count') await page.type('.govuk-js-character-count', 'Hello '.repeat(9)) - // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 300)) - const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 1 word remaining') @@ -216,9 +201,6 @@ describe('Character count', () => { beforeAll(async () => { await goToExample('with-word-count') await page.type('.govuk-js-character-count', 'Hello '.repeat(11)) - - // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 300)) }) it('shows the number of words over the limit', async () => { @@ -229,9 +211,6 @@ describe('Character count', () => { it('uses the plural when the limit is exceeded by 2 or more', async () => { await page.type('.govuk-js-character-count', 'World') - // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 300)) - const message = await page.$eval('.govuk-character-count__status', el => el.innerHTML.trim()) expect(message).toEqual('You have 2 words too many') }) From 77bf218b6462f8f00f167c418f9d92f50f227539 Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Fri, 8 Apr 2022 10:59:25 +0100 Subject: [PATCH 06/12] Fix character count not updating upon voice input --- src/govuk/components/character-count/character-count.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/govuk/components/character-count/character-count.js b/src/govuk/components/character-count/character-count.js index 97d936b78c..9ee2f414ad 100644 --- a/src/govuk/components/character-count/character-count.js +++ b/src/govuk/components/character-count/character-count.js @@ -231,6 +231,7 @@ CharacterCount.prototype.isOverThreshold = function () { CharacterCount.prototype.handleKeyUp = function () { // Update the visible character counter and keep track of when the update happened this.updateVisibleCountMessage() + this.lastInputTimestamp = Date.now() // Clear the previous timeout, as an update has just taken place, and set a new one clearTimeout(this.debouncedInputTimer) @@ -244,7 +245,7 @@ CharacterCount.prototype.handleFocus = function () { // conflict with debounced KeyboardEvent updates. this.valueChecker = setInterval(function () { if (!this.lastInputTimestamp || (Date.now() - 1000) >= this.lastInputTimestamp) { - this.checkIfValueChanged.bind(this) + this.checkIfValueChanged() } }.bind(this), 1000) } From 378562489d9447d3fff0c988e02bb0ec077b6a51 Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Fri, 8 Apr 2022 13:51:06 +0100 Subject: [PATCH 07/12] Add tests for new functionality --- .../character-count/character-count.test.js | 96 +++++++++++++++++-- 1 file changed, 87 insertions(+), 9 deletions(-) diff --git a/src/govuk/components/character-count/character-count.test.js b/src/govuk/components/character-count/character-count.test.js index 9be7b8b60c..729b91850f 100644 --- a/src/govuk/components/character-count/character-count.test.js +++ b/src/govuk/components/character-count/character-count.test.js @@ -31,21 +31,46 @@ 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__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__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 () => { @@ -53,8 +78,13 @@ describe('Character count', () => { await page.type('.govuk-js-character-count', 'A') 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, 1100)) + + 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 () => { @@ -62,8 +92,13 @@ describe('Character count', () => { await page.type('.govuk-js-character-count', 'A'.repeat(9)) 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, 1100)) + + 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', () => { @@ -75,6 +110,12 @@ describe('Character count', () => { it('shows the number of characters over the limit', async () => { 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, 1100)) + + 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 () => { @@ -82,6 +123,12 @@ describe('Character count', () => { 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, 1100)) + + 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 () => { @@ -103,6 +150,9 @@ describe('Character count', () => { it('shows the number of characters over the limit', async () => { 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 () => { @@ -154,8 +204,10 @@ describe('Character count', () => { await goToExample('with-id-starting-with-number') 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') }) }) @@ -164,8 +216,10 @@ describe('Character count', () => { await goToExample('with-id-with-special-characters') 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') }) }) }) @@ -175,8 +229,10 @@ describe('Character count', () => { await goToExample('with-word-count') 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 () => { @@ -184,8 +240,13 @@ describe('Character count', () => { await page.type('.govuk-js-character-count', 'Hello world') 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, 1100)) + + 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 () => { @@ -193,8 +254,13 @@ describe('Character count', () => { await page.type('.govuk-js-character-count', 'Hello '.repeat(9)) 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, 1100)) + + 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', () => { @@ -206,6 +272,12 @@ describe('Character count', () => { it('shows the number of words over the limit', async () => { 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, 1100)) + + 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 () => { @@ -213,6 +285,12 @@ describe('Character count', () => { 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, 1100)) + + 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 () => { From dd564e3138dc47d2cd8989dfae72bace632e0b8b Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Fri, 8 Apr 2022 15:31:45 +0100 Subject: [PATCH 08/12] Remove dedicated debounced update on keyup Some issues were being caused by there being two updates that happened after the user had stopped typing: One being the check that exists for voice dictation users (which is debounced so that it doesn't run the check if a user is typing), the other being the debounched update from the keyboard event handling. These updates could happen very close to one another, causing some screen readers to repeat the updated message or to stop announcing the updates altogether. --- .../character-count/character-count.js | 14 +++-------- .../character-count/character-count.test.js | 24 +++++++++++-------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/govuk/components/character-count/character-count.js b/src/govuk/components/character-count/character-count.js index 9ee2f414ad..eb981473e0 100644 --- a/src/govuk/components/character-count/character-count.js +++ b/src/govuk/components/character-count/character-count.js @@ -8,7 +8,6 @@ function CharacterCount ($module) { this.$visibleCountMessage = null this.$screenReaderCountMessage = null this.lastInputTimestamp = null - this.debouncedInputTimer = null } CharacterCount.prototype.defaults = { @@ -224,18 +223,11 @@ CharacterCount.prototype.isOverThreshold = function () { return (thresholdValue <= currentLength) } -// Handle the user typing. -// Debounces updating the screen reader counter until after a user has stopped -// typing for a short period of time. This helps prevent screen readers from -// queuing up multiple text updates in rapid succession. +// Update the visible character counter and keep track of when the last update +// happened for each keypress CharacterCount.prototype.handleKeyUp = function () { - // Update the visible character counter and keep track of when the update happened this.updateVisibleCountMessage() this.lastInputTimestamp = Date.now() - - // Clear the previous timeout, as an update has just taken place, and set a new one - clearTimeout(this.debouncedInputTimer) - this.debouncedInputTimer = setTimeout(this.updateScreenReaderCountMessage.bind(this), 500) } CharacterCount.prototype.handleFocus = function () { @@ -244,7 +236,7 @@ CharacterCount.prototype.handleFocus = function () { // 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() - 1000) >= this.lastInputTimestamp) { + if (!this.lastInputTimestamp || (Date.now() - 500) >= this.lastInputTimestamp) { this.checkIfValueChanged() } }.bind(this), 1000) diff --git a/src/govuk/components/character-count/character-count.test.js b/src/govuk/components/character-count/character-count.test.js index 729b91850f..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` @@ -81,7 +85,7 @@ describe('Character count', () => { expect(message).toEqual('You have 9 characters remaining') // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 1100)) + 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') @@ -95,7 +99,7 @@ describe('Character count', () => { expect(message).toEqual('You have 1 character remaining') // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 1100)) + 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') @@ -112,7 +116,7 @@ describe('Character count', () => { expect(message).toEqual('You have 1 character too many') // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 1100)) + 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') @@ -125,7 +129,7 @@ describe('Character count', () => { expect(message).toEqual('You have 2 characters too many') // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 1100)) + 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') @@ -176,7 +180,7 @@ describe('Character count', () => { expect(visibility).toEqual('hidden') // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 1100)) + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) // Ensure threshold is hidden for users of assistive technologies const ariaHidden = await page.$eval('.govuk-character-count__sr-status', el => el.getAttribute('aria-hidden')) @@ -190,7 +194,7 @@ describe('Character count', () => { expect(visibility).toEqual('visible') // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 1100)) + await new Promise((resolve) => setTimeout(resolve, debouncedWaitTime)) // Ensure threshold is visible for users of assistive technologies const ariaHidden = await page.$eval('.govuk-character-count__sr-status', el => el.getAttribute('aria-hidden')) @@ -243,7 +247,7 @@ describe('Character count', () => { expect(message).toEqual('You have 8 words remaining') // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 1100)) + 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') @@ -257,7 +261,7 @@ describe('Character count', () => { expect(message).toEqual('You have 1 word remaining') // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 1100)) + 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') @@ -274,7 +278,7 @@ describe('Character count', () => { expect(message).toEqual('You have 1 word too many') // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 1100)) + 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') @@ -287,7 +291,7 @@ describe('Character count', () => { expect(message).toEqual('You have 2 words too many') // Wait for debounced update to happen - await new Promise((resolve) => setTimeout(resolve, 1100)) + 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') From 174c4d0b464af5c421b5b3f7142b4d3217be0e98 Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Mon, 11 Apr 2022 09:28:39 +0100 Subject: [PATCH 09/12] Move variable existence check to before we try and use the variable --- .../components/character-count/character-count.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/govuk/components/character-count/character-count.js b/src/govuk/components/character-count/character-count.js index eb981473e0..130c4eeca3 100644 --- a/src/govuk/components/character-count/character-count.js +++ b/src/govuk/components/character-count/character-count.js @@ -17,16 +17,17 @@ 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 $fallbackLimitMessage = document.getElementById(this.$textarea.id + '-info') - - if (!$textarea) { - return - } + var $fallbackLimitMessage = document.getElementById($textarea.id + '-info') - // We move fallback count message right after the field + // Move the fallback count message to be immediately after the textarea // Kept for backwards compatibility $textarea.insertAdjacentElement('afterend', $fallbackLimitMessage) From 22be7cb2487719e8901c416ac2c23a84e02923f4 Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Mon, 11 Apr 2022 09:30:34 +0100 Subject: [PATCH 10/12] Make variable naming more consistent --- .../character-count/character-count.js | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/govuk/components/character-count/character-count.js b/src/govuk/components/character-count/character-count.js index 130c4eeca3..a0534b38b9 100644 --- a/src/govuk/components/character-count/character-count.js +++ b/src/govuk/components/character-count/character-count.js @@ -143,54 +143,54 @@ CharacterCount.prototype.updateCountMessage = function () { // Update visible counter CharacterCount.prototype.updateVisibleCountMessage = function () { - var countElement = this.$textarea - var countMessage = this.$visibleCountMessage - var remainingNumber = this.maxLength - this.count(countElement.value) + var $textarea = this.$textarea + var $visibleCountMessage = this.$visibleCountMessage + var remainingNumber = this.maxLength - this.count($textarea.value) // If input is over the threshold, remove the disabled class which renders the // counter invisible. if (this.isOverThreshold()) { - countMessage.classList.remove('govuk-character-count__message--disabled') + $visibleCountMessage.classList.remove('govuk-character-count__message--disabled') } else { - countMessage.classList.add('govuk-character-count__message--disabled') + $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 { - countElement.classList.remove('govuk-textarea--error') - countMessage.classList.remove('govuk-error-message') - countMessage.classList.add('govuk-hint') + $textarea.classList.remove('govuk-textarea--error') + $visibleCountMessage.classList.remove('govuk-error-message') + $visibleCountMessage.classList.add('govuk-hint') } // Update message - countMessage.innerHTML = this.formatUpdateMessage() + $visibleCountMessage.innerHTML = this.formatUpdateMessage() } // Update screen reader-specific counter CharacterCount.prototype.updateScreenReaderCountMessage = function () { - var countMessage = this.$screenReaderCountMessage + var $screenReaderCountMessage = this.$screenReaderCountMessage - // If other the threshold, remove the aria-hidden attribute, allowing screen + // If over the threshold, remove the aria-hidden attribute, allowing screen // readers to announce the content of the element. if (this.isOverThreshold()) { - countMessage.removeAttribute('aria-hidden') + $screenReaderCountMessage.removeAttribute('aria-hidden') } else { - countMessage.setAttribute('aria-hidden', true) + $screenReaderCountMessage.setAttribute('aria-hidden', true) } // Update message - countMessage.innerHTML = this.formatUpdateMessage() + $screenReaderCountMessage.innerHTML = this.formatUpdateMessage() } // Format update message CharacterCount.prototype.formatUpdateMessage = function () { - var countElement = this.$textarea + var $textarea = this.$textarea var options = this.options - var remainingNumber = this.maxLength - this.count(countElement.value) + var remainingNumber = this.maxLength - this.count($textarea.value) var charVerb = 'remaining' var charNoun = 'character' @@ -210,11 +210,11 @@ CharacterCount.prototype.formatUpdateMessage = function () { // If there is no configured threshold, it is set to 0 and this function will // always return true. CharacterCount.prototype.isOverThreshold = function () { - var countElement = this.$textarea + var $textarea = this.$textarea var options = this.options // Determine the remaining number of characters/words - var currentLength = this.count(countElement.value) + var currentLength = this.count($textarea.value) var maxLength = this.maxLength // Set threshold if presented in options From fe725e64da1fcf9f3cfa3217727c7ac9a10511c4 Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Mon, 11 Apr 2022 09:44:42 +0100 Subject: [PATCH 11/12] Rename function to make purpose clearer --- src/govuk/components/character-count/character-count.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/govuk/components/character-count/character-count.js b/src/govuk/components/character-count/character-count.js index a0534b38b9..fcc5a10f92 100644 --- a/src/govuk/components/character-count/character-count.js +++ b/src/govuk/components/character-count/character-count.js @@ -167,7 +167,7 @@ CharacterCount.prototype.updateVisibleCountMessage = function () { } // Update message - $visibleCountMessage.innerHTML = this.formatUpdateMessage() + $visibleCountMessage.innerHTML = this.formattedUpdateMessage() } // Update screen reader-specific counter @@ -183,11 +183,11 @@ CharacterCount.prototype.updateScreenReaderCountMessage = function () { } // Update message - $screenReaderCountMessage.innerHTML = this.formatUpdateMessage() + $screenReaderCountMessage.innerHTML = this.formattedUpdateMessage() } // Format update message -CharacterCount.prototype.formatUpdateMessage = function () { +CharacterCount.prototype.formattedUpdateMessage = function () { var $textarea = this.$textarea var options = this.options var remainingNumber = this.maxLength - this.count($textarea.value) From a876f418f22cdbf5e2252695fd0d2760615941d5 Mon Sep 17 00:00:00 2001 From: Kimberly Grey Date: Tue, 12 Apr 2022 09:24:26 +0100 Subject: [PATCH 12/12] Update changelog --- CHANGELOG.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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)