@indiekit/frontend
Version:
Frontend components for Indiekit
186 lines (165 loc) • 5.82 kB
JavaScript
/**
* Based on the Character count component from the GOV.UK Design System.
* Provides a visible, real-time character and word count, and visually
* hidden counter that announces updates to screen readers periodically.
*
* This component provides a character and word count, and doesn’t inform or
* enforce any arbitrary content limits.
* @see {@link https://design-system.service.gov.uk/components/character-count}
* @see {@link https://dav-idc.com/making-a-character-count-component-more-accessible}
*/
export const CharacterCountComponent = class extends HTMLElement {
constructor() {
super();
this.lastInputTimestamp = undefined;
this.lastInputValue = "";
this.valueChecker = undefined;
this.$screenReaderCountMessage = document.createElement("p");
this.$screenReaderCountMessage.className = "-!-visually-hidden";
this.$screenReaderCountMessage.setAttribute("aria-live", "polite");
this.$textareaDescription.insertAdjacentElement(
"afterend",
this.$screenReaderCountMessage,
);
this.$visibleCountMessage = document.createElement("p");
this.$visibleCountMessage.className = this.$textareaDescription.className;
this.$visibleCountMessage.setAttribute("aria-hidden", "true");
}
connectedCallback() {
this.i18nChar = this.getAttribute("i18n-char") || `%s character`;
this.i18nChars = this.getAttribute("i18n-chars") || `%s characters`;
this.i18nWord = this.getAttribute("i18n-word") || `%s word`;
this.i18nWords = this.getAttribute("i18n-words") || `%s words`;
this.$textarea = this.querySelector("textarea");
this.$textarea.addEventListener("keyup", this.#handleKeyUp.bind(this));
this.$textarea.addEventListener("focus", this.#handleFocus.bind(this));
this.$textarea.addEventListener("blur", this.#handleBlur.bind(this));
window.addEventListener("pageshow", this.#updateCountMessages.bind(this));
this.#updateCountMessages();
this.$textareaDescription = this.querySelector(
`#${this.$textarea.id}-info`,
);
this.$textareaDescription.classList.add("-!-visually-hidden");
this.$textareaDescription.insertAdjacentElement(
"afterend",
this.$visibleCountMessage,
);
}
/**
* Update visible character counter and keep track of when the last update
* happened for each keypress
* @access private
*/
#handleKeyUp() {
this.#updateVisibleCountMessage();
this.lastInputTimestamp = Date.now();
}
/**
* Handle focus event
*
* Speech recognition software such as Dragon NaturallySpeaking will modify
* a field by directly changing its `value`. These changes don’t trigger
* events in JavaScript, so we need to poll to handle when and if they occur.
*
* Once the keyup event hasn’t been detected for at least 1000 ms (1s), check
* if textarea value has changed and update count message if it has.
*
* This is so that update triggered by manual comparison doesn’t conflict
* with debounced KeyboardEvent updates.
* @access private
*/
#handleFocus() {
this.valueChecker = globalThis.setInterval(() => {
if (
!this.lastInputTimestamp ||
Date.now() - 500 >= this.lastInputTimestamp
) {
this.#updateIfValueChanged();
}
}, 1000);
}
/**
* Stop checking textarea value once it no longer has focus
* @access private
*/
#handleBlur() {
if (this.valueChecker) {
globalThis.clearInterval(this.valueChecker);
}
}
/**
* Update count message if textarea value has changed
* @access private
*/
#updateIfValueChanged() {
if (this.$textarea.value !== this.lastInputValue) {
this.lastInputValue = this.$textarea.value;
this.#updateCountMessages();
}
}
/**
* Helper method to update both visible and screen reader-specific counters
* simultaneously (e.g. on init)
* @access private
*/
#updateCountMessages() {
this.#updateVisibleCountMessage();
this.#updateScreenReaderCountMessage();
}
/**
* Update visible count message
* @access private
*/
#updateVisibleCountMessage() {
this.$visibleCountMessage.textContent = this.#countMessage;
}
/**
* Update screen reader count message
* @access private
*/
#updateScreenReaderCountMessage() {
this.$screenReaderCountMessage.textContent = this.#countMessage;
}
/**
* Count number of characters (or words) in given string
* @access private
* @param {string} string - The text to count the characters of
* @param {boolean} [countWords] - Count words instead of characters
* @returns {number} the number of characters (or words) in the text
*/
#count(string, countWords = false) {
if (countWords) {
// Matches consecutive non-whitespace characters up to a word boundary
const tokens = string.match(/\S+\b/g) ?? [];
return tokens.length;
}
return string.length;
}
/**
* Get count message
* @access private
* @returns {string} Status message
*/
get #countMessage() {
return this.#formatCountMessage(this.$textarea.value);
}
/**
* Format and localise count shown to users
* @access private
* @param {string} string - Number of characters remaining
* @returns {string} Status message
*/
#formatCountMessage(string) {
const characters = this.#count(string);
const words = this.#count(string, true);
let characterCount = this.i18nChars.replace("%s", String(characters));
if (characters === 1) {
characterCount = this.i18nChar.replace("%s", String(characters));
}
let wordCount = this.i18nWords.replace("%s", String(words));
if (words === 1) {
wordCount = this.i18nWord.replace("%s", String(words));
}
return `${characterCount}, ${wordCount}`;
}
};