@uswds/uswds
Version:
Open source UI components and visual style guide for U.S. government websites
300 lines (253 loc) • 10.2 kB
JavaScript
const select = require("../../uswds-core/src/js/utils/select");
const behavior = require("../../uswds-core/src/js/utils/behavior");
const debounce = require("../../uswds-core/src/js/utils/debounce");
const { prefix: PREFIX } = require("../../uswds-core/src/js/config");
const CHARACTER_COUNT_CLASS = `${PREFIX}-character-count`;
const CHARACTER_COUNT = `.${CHARACTER_COUNT_CLASS}`;
const FORM_GROUP_CLASS = `${PREFIX}-form-group`;
const FORM_GROUP_ERROR_CLASS = `${FORM_GROUP_CLASS}--error`;
const FORM_GROUP = `.${FORM_GROUP_CLASS}`;
const LABEL_CLASS = `${PREFIX}-label`;
const LABEL_ERROR_CLASS = `${LABEL_CLASS}--error`;
const INPUT = `.${PREFIX}-character-count__field`;
const INPUT_ERROR_CLASS = `${PREFIX}-input--error`;
const MESSAGE = `.${PREFIX}-character-count__message`;
const VALIDATION_MESSAGE = "The content is too long.";
const MESSAGE_INVALID_CLASS = `${PREFIX}-character-count__status--invalid`;
const STATUS_MESSAGE_CLASS = `${CHARACTER_COUNT_CLASS}__status`;
const STATUS_MESSAGE_SR_ONLY_CLASS = `${CHARACTER_COUNT_CLASS}__sr-status`;
const STATUS_MESSAGE = `.${STATUS_MESSAGE_CLASS}`;
const STATUS_MESSAGE_SR_ONLY = `.${STATUS_MESSAGE_SR_ONLY_CLASS}`;
const DEFAULT_STATUS_LABEL = `characters allowed`;
const LIMIT_EXCEEDED_MESSAGE = "Character limit exceeded.";
const SR_STATUS_DEBOUNCE_MS = 1200;
const AT_DEFER_MS = 100;
/**
* Returns the root, form group, label, and message elements for an character count input
*
* @param {HTMLInputElement|HTMLTextAreaElement} inputEl The character count input element
* @returns {CharacterCountElements} elements The root form group, label, and message element.
*/
const getCharacterCountElements = (inputEl) => {
const characterCountEl = inputEl.closest(CHARACTER_COUNT);
if (!characterCountEl) {
throw new Error(`${INPUT} is missing outer ${CHARACTER_COUNT}`);
}
const formGroupEl = characterCountEl.querySelector(FORM_GROUP);
const labelEl = document.querySelector(
`label[for="${inputEl.getAttribute("id")}"]`,
);
const messageEl = characterCountEl.querySelector(MESSAGE);
if (!messageEl) {
throw new Error(`${CHARACTER_COUNT} is missing inner ${MESSAGE}`);
}
return { characterCountEl, formGroupEl, labelEl, messageEl };
};
/**
* Returns the configured maximum character length for a character count root.
*
* @param {HTMLElement} characterCountEl - Div with `.usa-character-count` class
* @returns {number} The maximum character length, or NaN when not configured
*/
const getMaxLength = (characterCountEl) =>
parseInt(characterCountEl.getAttribute("data-maxlength"), 10);
/**
* Move maxlength attribute to a data attribute on usa-character-count
*
* @param {HTMLInputElement|HTMLTextAreaElement} inputEl The character count input element
*/
const setDataLength = (inputEl) => {
const { characterCountEl } = getCharacterCountElements(inputEl);
const maxlength = inputEl.getAttribute("maxlength");
if (!maxlength) return;
inputEl.removeAttribute("maxlength");
characterCountEl.setAttribute("data-maxlength", maxlength);
};
/**
* Create and append status messages for visual and screen readers
*
* @param {HTMLDivElement} characterCountEl - Div with `.usa-character-count` class
* @description Create two status messages for number of characters left;
* one visual status and another for screen readers
*/
const createStatusMessages = (characterCountEl) => {
if (characterCountEl.querySelector(STATUS_MESSAGE_SR_ONLY)) {
return;
}
const statusMessage = document.createElement("div");
const srStatusMessage = document.createElement("div");
const maxLength = getMaxLength(characterCountEl);
const defaultMessage = `${maxLength} ${DEFAULT_STATUS_LABEL}`;
statusMessage.classList.add(STATUS_MESSAGE_CLASS, "usa-hint");
srStatusMessage.classList.add(STATUS_MESSAGE_SR_ONLY_CLASS, "usa-sr-only");
statusMessage.setAttribute("aria-hidden", true);
statusMessage.textContent = defaultMessage;
srStatusMessage.textContent = defaultMessage;
characterCountEl.append(statusMessage, srStatusMessage);
// Defer aria-live to prevent iOS VoiceOver from announcing on page load.
// Browsers treat initial content of a newly-inserted aria-live region as
// a live update. Adding the attribute after the element is in the DOM
// and after a short delay avoids the false announcement.
setTimeout(() => {
srStatusMessage.setAttribute("aria-live", "polite");
}, AT_DEFER_MS);
};
/**
* Returns message with how many characters are left
*
* @param {number} currentLength - The number of characters used
* @param {number} maxLength - The total number of characters allowed
* @returns {string} A string description of how many characters are left
*/
const getCountMessage = (currentLength, maxLength) => {
if (currentLength === 0) {
return `${maxLength} ${DEFAULT_STATUS_LABEL}`;
}
const difference = Math.abs(maxLength - currentLength);
const characters = `character${difference === 1 ? "" : "s"}`;
const guidance = currentLength > maxLength ? "over limit" : "left";
return `${difference} ${characters} ${guidance}`;
};
/**
* Updates the screen reader status message and aria-live politeness.
*
* @param {HTMLElement} msgEl - The screen reader status message element
* @param {string} statusMessage - A string of the current character status
* @param {boolean} assertive - Whether to announce assertively
*/
const updateSrStatus = (msgEl, statusMessage, assertive = false) => {
msgEl.setAttribute("aria-live", assertive ? "assertive" : "polite");
msgEl.textContent = statusMessage;
};
/**
* Updates the character count status for screen readers after typing pauses.
*
* @param {HTMLElement} msgEl - The screen reader status message element
* @param {string} statusMessage - A string of the current character status
* @param {boolean} assertive - Whether to announce assertively when over the limit
*/
const srUpdateStatus = debounce(updateSrStatus, SR_STATUS_DEBOUNCE_MS);
const srUpdateStatusRecovery = debounce(updateSrStatus, AT_DEFER_MS);
const validitySyncTimers = new WeakMap();
/**
* Defer constraint validation updates so Safari VoiceOver can finish character
* echo before the field's invalid state changes (which re-reads the control).
*
* @param {HTMLInputElement|HTMLTextAreaElement} inputEl
* @param {boolean} isOverLimit
*/
const scheduleValiditySync = (inputEl, isOverLimit) => {
window.clearTimeout(validitySyncTimers.get(inputEl));
if (isOverLimit && !inputEl.validationMessage) {
inputEl.setCustomValidity(VALIDATION_MESSAGE);
return;
}
if (!isOverLimit && inputEl.validationMessage === VALIDATION_MESSAGE) {
validitySyncTimers.set(
inputEl,
window.setTimeout(() => {
if (inputEl.validationMessage === VALIDATION_MESSAGE) {
inputEl.setCustomValidity("");
}
}, AT_DEFER_MS),
);
}
};
/**
* Update the character count component
*
* @description On input, it will update visual status, screenreader
* status and update input validation (if over character length)
* @param {HTMLInputElement|HTMLTextAreaElement} inputEl The character count input element
*/
const updateCountMessage = (inputEl) => {
const { characterCountEl, labelEl, formGroupEl } =
getCharacterCountElements(inputEl);
const currentLength = inputEl.value.length;
const maxLength = getMaxLength(characterCountEl);
const statusMessage = characterCountEl.querySelector(STATUS_MESSAGE);
const srStatusMessage = characterCountEl.querySelector(
STATUS_MESSAGE_SR_ONLY,
);
const currentStatusMessage = getCountMessage(currentLength, maxLength);
if (!maxLength) return;
const isOverLimit = currentLength > maxLength;
const wasOverLimit = inputEl.dataset.characterCountOverLimit === "true";
const crossedUnderLimit = wasOverLimit && !isOverLimit;
statusMessage.textContent = currentStatusMessage;
if (crossedUnderLimit) {
srUpdateStatus.cancel();
srUpdateStatusRecovery(srStatusMessage, currentStatusMessage);
} else if (isOverLimit) {
srUpdateStatusRecovery.cancel();
srUpdateStatus(
srStatusMessage,
`${LIMIT_EXCEEDED_MESSAGE} ${currentStatusMessage}`,
true,
);
} else {
srUpdateStatusRecovery.cancel();
srUpdateStatus(srStatusMessage, currentStatusMessage);
}
inputEl.dataset.characterCountOverLimit = isOverLimit ? "true" : "false";
scheduleValiditySync(inputEl, isOverLimit);
if (formGroupEl) {
formGroupEl.classList.toggle(FORM_GROUP_ERROR_CLASS, isOverLimit);
}
if (labelEl) {
labelEl.classList.toggle(LABEL_ERROR_CLASS, isOverLimit);
}
inputEl.classList.toggle(INPUT_ERROR_CLASS, isOverLimit);
statusMessage.classList.toggle(MESSAGE_INVALID_CLASS, isOverLimit);
};
/**
* Initialize component
*
* @description On init this function will create elements and update any
* attributes so it can tell the user how many characters are left.
* @param {HTMLInputElement|HTMLTextAreaElement} inputEl the components input
*/
const enhanceCharacterCount = (inputEl) => {
if (inputEl.dataset.enhanced) return;
const { characterCountEl, messageEl } = getCharacterCountElements(inputEl);
// Hide hint and remove aria-live for backwards compatibility
messageEl.classList.add("usa-sr-only");
messageEl.removeAttribute("aria-live");
setDataLength(inputEl);
createStatusMessages(characterCountEl);
const maxLength = getMaxLength(characterCountEl);
if (maxLength) {
inputEl.dataset.characterCountOverLimit =
inputEl.value.length > maxLength ? "true" : "false";
}
inputEl.dataset.enhanced = "true";
};
const characterCount = behavior(
{
input: {
[INPUT]() {
updateCountMessage(this);
},
},
},
{
init(root) {
select(INPUT, root).forEach((input) => enhanceCharacterCount(input));
},
FORM_GROUP_ERROR_CLASS,
LABEL_ERROR_CLASS,
INPUT_ERROR_CLASS,
MESSAGE_INVALID_CLASS,
VALIDATION_MESSAGE,
STATUS_MESSAGE_CLASS,
STATUS_MESSAGE_SR_ONLY_CLASS,
DEFAULT_STATUS_LABEL,
LIMIT_EXCEEDED_MESSAGE,
SR_STATUS_DEBOUNCE_MS,
AT_DEFER_MS,
createStatusMessages,
getCountMessage,
updateCountMessage,
},
);
module.exports = characterCount;