Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Refactor character count to inject new element #2577

Merged
merged 12 commits into from
Apr 12, 2022
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ exports[`Character count when it includes a hint renders with hint 1`] = `
</div>
<div id="with-hint-info"
class="govuk-hint govuk-character-count__message"
aria-live="polite"
>
You can enter up to 10 characters
</div>
Expand Down
167 changes: 119 additions & 48 deletions src/govuk/components/character-count/character-count.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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)
Expand All @@ -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()
}

Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand All @@ -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 () {
Expand Down
Loading