govuk-frontend
Version:
GOV.UK Frontend contains the code you need to start building a user interface for government platforms and services.
297 lines (290 loc) • 12.4 kB
JavaScript
import { closestAttributeValue } from '../../common/closest-attribute-value.mjs';
import { ConfigurableComponent, configOverride, validateConfig } from '../../common/configuration.mjs';
import { formatErrorMessage } from '../../common/index.mjs';
import { ElementError, ConfigError } from '../../errors/index.mjs';
import { I18n } from '../../i18n.mjs';
/**
* Character count component
*
* Tracks the number of characters or words in the `.govuk-js-character-count`
* `<textarea>` inside the element. Displays a message with the remaining number
* of characters/words available, or the number of characters/words in excess.
*
* You can configure the message to only appear after a certain percentage
* of the available characters/words has been entered.
*
* @preserve
* @augments ConfigurableComponent<CharacterCountConfig>
*/
class CharacterCount extends ConfigurableComponent {
[configOverride](datasetConfig) {
let configOverrides = {};
if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
configOverrides = {
maxlength: undefined,
maxwords: undefined
};
}
return configOverrides;
}
/**
* @param {Element | null} $root - HTML element to use for character count
* @param {CharacterCountConfig} [config] - Character count config
*/
constructor($root, config = {}) {
var _ref, _this$config$maxwords;
super($root, config);
this.$textarea = void 0;
this.$visibleCountMessage = void 0;
this.$screenReaderCountMessage = void 0;
this.lastInputTimestamp = null;
this.lastInputValue = '';
this.valueChecker = null;
this.i18n = void 0;
this.maxLength = void 0;
const $textarea = this.$root.querySelector('.govuk-js-character-count');
if (!($textarea instanceof HTMLTextAreaElement || $textarea instanceof HTMLInputElement)) {
throw new ElementError({
component: CharacterCount,
element: $textarea,
expectedType: 'HTMLTextareaElement or HTMLInputElement',
identifier: 'Form field (`.govuk-js-character-count`)'
});
}
const errors = validateConfig(CharacterCount.schema, this.config);
if (errors[0]) {
throw new ConfigError(formatErrorMessage(CharacterCount, errors[0]));
}
this.i18n = new I18n(this.config.i18n, {
locale: closestAttributeValue(this.$root, 'lang')
});
this.maxLength = (_ref = (_this$config$maxwords = this.config.maxwords) != null ? _this$config$maxwords : this.config.maxlength) != null ? _ref : Infinity;
this.$textarea = $textarea;
const textareaDescriptionId = `${this.$textarea.id}-info`;
const $textareaDescription = document.getElementById(textareaDescriptionId);
if (!$textareaDescription) {
throw new ElementError({
component: CharacterCount,
element: $textareaDescription,
identifier: `Count message (\`id="${textareaDescriptionId}"\`)`
});
}
if (`${$textareaDescription.textContent}`.match(/^\s*$/)) {
$textareaDescription.textContent = this.i18n.t('textareaDescription', {
count: this.maxLength
});
}
this.$textarea.insertAdjacentElement('afterend', $textareaDescription);
const $screenReaderCountMessage = document.createElement('div');
$screenReaderCountMessage.className = 'govuk-character-count__sr-status govuk-visually-hidden';
$screenReaderCountMessage.setAttribute('aria-live', 'polite');
this.$screenReaderCountMessage = $screenReaderCountMessage;
$textareaDescription.insertAdjacentElement('afterend', $screenReaderCountMessage);
const $visibleCountMessage = document.createElement('div');
$visibleCountMessage.className = $textareaDescription.className;
$visibleCountMessage.classList.add('govuk-character-count__status');
$visibleCountMessage.setAttribute('aria-hidden', 'true');
this.$visibleCountMessage = $visibleCountMessage;
$textareaDescription.insertAdjacentElement('afterend', $visibleCountMessage);
$textareaDescription.classList.add('govuk-visually-hidden');
this.$textarea.removeAttribute('maxlength');
this.bindChangeEvents();
window.addEventListener('pageshow', () => this.updateCountMessage());
this.updateCountMessage();
}
bindChangeEvents() {
this.$textarea.addEventListener('keyup', () => this.handleKeyUp());
this.$textarea.addEventListener('focus', () => this.handleFocus());
this.$textarea.addEventListener('blur', () => this.handleBlur());
}
handleKeyUp() {
this.updateVisibleCountMessage();
this.lastInputTimestamp = Date.now();
}
handleFocus() {
this.valueChecker = window.setInterval(() => {
if (!this.lastInputTimestamp || Date.now() - 500 >= this.lastInputTimestamp) {
this.updateIfValueChanged();
}
}, 1000);
}
handleBlur() {
if (this.valueChecker) {
window.clearInterval(this.valueChecker);
}
}
updateIfValueChanged() {
if (this.$textarea.value !== this.lastInputValue) {
this.lastInputValue = this.$textarea.value;
this.updateCountMessage();
}
}
updateCountMessage() {
this.updateVisibleCountMessage();
this.updateScreenReaderCountMessage();
}
updateVisibleCountMessage() {
const remainingNumber = this.maxLength - this.count(this.$textarea.value);
const isError = remainingNumber < 0;
this.$visibleCountMessage.classList.toggle('govuk-character-count__message--disabled', !this.isOverThreshold());
this.$textarea.classList.toggle('govuk-textarea--error', isError);
this.$visibleCountMessage.classList.toggle('govuk-error-message', isError);
this.$visibleCountMessage.classList.toggle('govuk-hint', !isError);
this.$visibleCountMessage.textContent = this.getCountMessage();
}
updateScreenReaderCountMessage() {
if (this.isOverThreshold()) {
this.$screenReaderCountMessage.removeAttribute('aria-hidden');
} else {
this.$screenReaderCountMessage.setAttribute('aria-hidden', 'true');
}
this.$screenReaderCountMessage.textContent = this.getCountMessage();
}
count(text) {
if (this.config.maxwords) {
var _text$match;
const tokens = (_text$match = text.match(/\S+/g)) != null ? _text$match : [];
return tokens.length;
}
return text.length;
}
getCountMessage() {
const remainingNumber = this.maxLength - this.count(this.$textarea.value);
const countType = this.config.maxwords ? 'words' : 'characters';
return this.formatCountMessage(remainingNumber, countType);
}
formatCountMessage(remainingNumber, countType) {
if (remainingNumber === 0) {
return this.i18n.t(`${countType}AtLimit`);
}
const translationKeySuffix = remainingNumber < 0 ? 'OverLimit' : 'UnderLimit';
return this.i18n.t(`${countType}${translationKeySuffix}`, {
count: Math.abs(remainingNumber)
});
}
isOverThreshold() {
if (!this.config.threshold) {
return true;
}
const currentLength = this.count(this.$textarea.value);
const maxLength = this.maxLength;
const thresholdValue = maxLength * this.config.threshold / 100;
return thresholdValue <= currentLength;
}
}
/**
* Character count config
*
* @see {@link CharacterCount.defaults}
* @typedef {object} CharacterCountConfig
* @property {number} [maxlength] - The maximum number of characters.
* If maxwords is provided, the maxlength option will be ignored.
* @property {number} [maxwords] - The maximum number of words. If maxwords is
* provided, the maxlength option will be ignored.
* @property {number} [threshold=0] - The percentage value of the limit at
* which point the count message is displayed. If this attribute is set, the
* count message will be hidden by default.
* @property {CharacterCountTranslations} [i18n=CharacterCount.defaults.i18n] - Character count translations
*/
/**
* Character count translations
*
* @see {@link CharacterCount.defaults.i18n}
* @typedef {object} CharacterCountTranslations
*
* Messages shown to users as they type. It provides feedback on how many words
* or characters they have remaining or if they are over the limit. This also
* includes a message used as an accessible description for the textarea.
* @property {TranslationPluralForms} [charactersUnderLimit] - Message displayed
* when the number of characters is under the configured maximum, `maxlength`.
* This message is displayed visually and through assistive technologies. The
* component will replace the `%{count}` placeholder with the number of
* remaining characters. This is a [pluralised list of
* messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
* @property {string} [charactersAtLimit] - Message displayed when the number of
* characters reaches the configured maximum, `maxlength`. This message is
* displayed visually and through assistive technologies.
* @property {TranslationPluralForms} [charactersOverLimit] - Message displayed
* when the number of characters is over the configured maximum, `maxlength`.
* This message is displayed visually and through assistive technologies. The
* component will replace the `%{count}` placeholder with the number of
* remaining characters. This is a [pluralised list of
* messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
* @property {TranslationPluralForms} [wordsUnderLimit] - Message displayed when
* the number of words is under the configured maximum, `maxlength`. This
* message is displayed visually and through assistive technologies. The
* component will replace the `%{count}` placeholder with the number of
* remaining words. This is a [pluralised list of
* messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
* @property {string} [wordsAtLimit] - Message displayed when the number of
* words reaches the configured maximum, `maxlength`. This message is
* displayed visually and through assistive technologies.
* @property {TranslationPluralForms} [wordsOverLimit] - Message displayed when
* the number of words is over the configured maximum, `maxlength`. This
* message is displayed visually and through assistive technologies. The
* component will replace the `%{count}` placeholder with the number of
* remaining words. This is a [pluralised list of
* messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend).
* @property {TranslationPluralForms} [textareaDescription] - Message made
* available to assistive technologies, if none is already present in the
* HTML, to describe that the component accepts only a limited amount of
* content. It is visible on the page when JavaScript is unavailable. The
* component will replace the `%{count}` placeholder with the value of the
* `maxlength` or `maxwords` parameter.
*/
/**
* @typedef {import('../../common/configuration.mjs').Schema} Schema
* @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
*/
CharacterCount.moduleName = 'govuk-character-count';
CharacterCount.defaults = Object.freeze({
threshold: 0,
i18n: {
charactersUnderLimit: {
one: 'You have %{count} character remaining',
other: 'You have %{count} characters remaining'
},
charactersAtLimit: 'You have 0 characters remaining',
charactersOverLimit: {
one: 'You have %{count} character too many',
other: 'You have %{count} characters too many'
},
wordsUnderLimit: {
one: 'You have %{count} word remaining',
other: 'You have %{count} words remaining'
},
wordsAtLimit: 'You have 0 words remaining',
wordsOverLimit: {
one: 'You have %{count} word too many',
other: 'You have %{count} words too many'
},
textareaDescription: {
other: ''
}
}
});
CharacterCount.schema = Object.freeze({
properties: {
i18n: {
type: 'object'
},
maxwords: {
type: 'number'
},
maxlength: {
type: 'number'
},
threshold: {
type: 'number'
}
},
anyOf: [{
required: ['maxwords'],
errorMessage: 'Either "maxlength" or "maxwords" must be provided'
}, {
required: ['maxlength'],
errorMessage: 'Either "maxlength" or "maxwords" must be provided'
}]
});
export { CharacterCount };
//# sourceMappingURL=character-count.mjs.map