/* eslint max-lines: ["error", {"max": 350, "skipBlankLines": true}] */ const COUNT_KEY = "%count%"; // How often SR announces the message in relation to maximum characters. E.g. // if max characters is 1000, screen reader announces the remaining characters // every 100 (= 0.1 * 1000) characters. This will be "floored" to the closest // 100 if the maximum characters > 100. E.g. if max characters is 5500, the // threshold is 500 (= Math.floor(550 / 100) * 100). With 100 or less // characters, this ratio is omitted and the announce threshold is always set to // 10. const SR_ANNOUNCE_THRESHOLD_RATIO = 0.1; // The number of characters left after which every keystroke will be announced. const SR_ANNOUNCE_EVERY_THRESHOLD = 10; const DEFAULT_MESSAGES = { charactersAtLeast: { one: `at least ${COUNT_KEY} character`, other: `at least ${COUNT_KEY} characters` }, charactersLeft: { one: `${COUNT_KEY} character left`, other: `${COUNT_KEY} characters left` } }; let MESSAGES = DEFAULT_MESSAGES; export default class InputCharacterCounter { static configureMessages(messages) { MESSAGES = $.extend(DEFAULT_MESSAGES, messages); } constructor(input) { this.$input = input; this.$target = $(this.$input.data("remaining-characters")); this.minCharacters = parseInt(this.$input.attr("minlength"), 10); this.maxCharacters = parseInt(this.$input.attr("maxlength"), 10); this.describeByCounter = typeof this.$input.attr("aria-describedby") === "undefined"; // Define the closest length for the input "gaps" defined by the threshold. if (this.maxCharacters > 10) { if (this.maxCharacters > 100) { this.announceThreshold = Math.floor(this.maxCharacters * SR_ANNOUNCE_THRESHOLD_RATIO); } else { this.announceThreshold = 10; } // The number of characters left after which every keystroke will be announced. this.announceEveryThreshold = SR_ANNOUNCE_EVERY_THRESHOLD; } else { this.announceThreshold = 1; this.announceEveryThreshold = 1; } let targetId = this.$target.attr("id"); if (typeof targetId === "undefined") { if (this.$input.attr("id") && this.$input.attr("id").length > 0) { targetId = `${this.$input.attr("id")}_characters`; } else { targetId = `characters_${Math.random().toString(36).substr(2, 9)}`; } } if (this.$target.length > 0) { this.$target.attr("id", targetId) } else { this.$target = $(``) // If input is a hidden for WYSIWYG editor add it at the end if (this.$input.parent().is(".editor")) { this.$input.parent().after(this.$target); } // Prefix and suffix columns are wrapped in columns, so put the // character counter before that. else if ( this.$input.parent().is(".columns") && this.$input.parent().parent().is(".row") ) { this.$input.parent().parent().after(this.$target); } else { this.$input.after(this.$target); } } if (this.$target.length > 0 && (this.maxCharacters > 0 || this.minCharacters > 0)) { // Create the screen reader target element. We don't want to constantly // announce every change to screen reader, only occasionally. this.$srTarget = $( `` ); this.$target.before(this.$srTarget); this.$target.attr("aria-hidden", "true"); this.$userInput = this.$input; // In WYSIWYG editors (Quill) we need to find the active editor from the // DOM node. Quill has the experimental "find" method that should work // fine in this case if (Quill && this.$input.parent().is(".editor")) { // Wait until the next javascript loop so Quill editors are created setTimeout(() => { this.editor = Quill.find(this.$input.siblings(".editor-container")[0]); this.$userInput = $(this.editor.root); this.initialize(); }); } else { this.initialize(); } } } initialize() { this.updateInputLength(); this.previousInputLength = this.inputLength; this.bindEvents(); this.setDescribedBy(true); } setDescribedBy(active) { if (!this.describeByCounter) { return; } if (active) { this.$userInput.attr("aria-describedby", this.$srTarget.attr("id")); } else { this.$userInput.removeAttr("aria-describedby"); } } bindEvents() { if (this.editor) { this.editor.on("text-change", () => { this.handleInput(); }); } else { this.$userInput.on("input", () => { this.handleInput(); }); } this.$userInput.on("keyup", () => { this.updateStatus(); }); this.$userInput.on("focus", () => { this.updateScreenReaderStatus(); }); this.$userInput.on("blur", () => { this.updateScreenReaderStatus(); this.setDescribedBy(true); }); if (this.$userInput.get(0) !== null) { this.$userInput.get(0).addEventListener("emoji.added", () => { this.updateStatus(); }); } this.updateStatus(); this.updateScreenReaderStatus(); } getInputLength() { return this.inputLength; } updateInputLength() { this.previousInputLength = this.inputLength; if (this.editor) { this.inputLength = this.editor.getLength(); } else { this.inputLength = this.$input.val().length; } } handleInput() { this.updateInputLength(); this.checkScreenReaderUpdate(); // If the input is "described by" the character counter, some screen // readers (NVDA) announce the status twice when it is updated. By // removing the aria-describedby attribute while the user is typing makes // the screen reader announce the status only once. this.setDescribedBy(false); } /** * This compares the current inputLength to the previous value and decides * whether the user is currently adding or deleting characters from the view. * * @returns {String} The input direction either "ins" for insert or "del" for * delete. */ getInputDirection() { if (this.inputLength < this.previousInputLength) { return "del"; } return "ins"; } getScreenReaderLength() { const currentLength = this.getInputLength(); if (this.maxCharacters < 10) { return currentLength; } else if (this.maxCharacters - currentLength <= this.announceEveryThreshold) { return currentLength; } const srLength = currentLength - currentLength % this.announceThreshold; // Prevent the screen reader telling too many characters left if the user // deletes a characters. This can cause confusing experience e.g. when the // user is closing the maximum amount of characters, so if the previous // announcement was "10 characters left" and the user removes one character, // the screen reader would announce "100 characters left" next time (when // they actually have only 11 characters left). Similar when they are // deleting a character at 900 characters, the screen reader would announce // "1000 characters left" even when they only have 901 characters left. if (this.getInputDirection() === "del") { // The first branch makes sure that if the SR length matches the actual // length, it will be always announced. if (srLength === currentLength) { return srLength; // The second branch checks that if we are at the final threshold, we // should not announce "0 characters left" when the user deletes more than // the "announce after every stroke" limit (this.announceEveryThreshold). } else if (this.maxCharacters - srLength === this.announceThreshold) { return this.announcedAt || currentLength; // The third branch checks that when deleting characters, we should // announce the next threshold to get accurate annoucement. E.g. when we // have 750 characters left and the user deletes 100 characters at once, // we should announce "700 characters left" after that deletion. } else if (srLength < currentLength) { return srLength + this.announceThreshold; } // This fixes an issue in the following situation: // 1. 750 characters left // 2. Delete 100 characters in a row // 3. SR: "800 characters left" (actual 850) // 4. Type one additional character // 5. Without this, SR would announce "900 characters left" = confusing } else if (srLength < this.announcedAt) { return this.announcedAt; } return srLength; } getMessages(currentLength = null) { const showMessages = []; let inputLength = currentLength; if (inputLength === null) { inputLength = this.getInputLength() } if (this.minCharacters > 0) { let message = MESSAGES.charactersAtLeast.other; if (this.minCharacters === 1) { message = MESSAGES.charactersAtLeast.one; } showMessages.push(message.replace(COUNT_KEY, this.minCharacters)); } if (this.maxCharacters > 0) { const remaining = this.maxCharacters - inputLength; let message = MESSAGES.charactersLeft.other; if (remaining === 1) { message = MESSAGES.charactersLeft.one; } this.$input[0].dispatchEvent( new CustomEvent("characterCounter", {detail: {remaining: remaining}}) ); showMessages.push(message.replace(COUNT_KEY, remaining)); } return showMessages; } updateStatus() { this.$target.text(this.getMessages().join(", ")); } checkScreenReaderUpdate() { if (this.maxCharacters < 1) { return; } const currentLength = this.getScreenReaderLength(); if (currentLength === this.announcedAt) { return; } this.announcedAt = currentLength; this.updateScreenReaderStatus(currentLength); } updateScreenReaderStatus(currentLength = null) { this.$srTarget.text(this.getMessages(currentLength).join(", ")); } } const createCharacterCounter = ($input) => { if (typeof $input !== "undefined" && $input.length) { $input.data("remaining-characters-counter", new InputCharacterCounter($input)); } } $(() => { $("input[type='text'], textarea, .editor>input[type='hidden']").each((_i, elem) => { const $input = $(elem); if (!$input.is("[minlength]") && !$input.is("[maxlength]")) { return; } createCharacterCounter($input); }); }); export {InputCharacterCounter, createCharacterCounter};