validation-enhancer.js (15483B)
1 /** 2 * validation-enhancer v1.0.3 3 * https://www.npmjs.com/package/validation-enhancer 4 * 5 * Custom element that displays inline constraint-validation feedback. 6 * Applies `.valid` / `.invalid` classes to each input and scrolls the 7 * first invalid input into view when the user tries to submit. 8 * 9 * Usage: wrap a `<form>` with `<validation-enhancer>`. 10 * 11 * Each input's error text is rendered inside the element referenced by 12 * that input's `aria-errormessage` attribute (customisable via 13 * `message-target-attr`). 14 * 15 * Per-input custom messages are supported via `validation-[validityKey]` 16 * or `data-validation-[validityKey]` attributes, for example 17 * `validation-valueMissing="Please fill this out"`. The prefix defaults 18 * to `"validation"` and can be changed with the `message-prefix` attribute. 19 * 20 * See the constraint-validation API for supported validity keys and elements: 21 * https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation#the_constraint_validation_api 22 * 23 * Note: when changing a input's value in code, dispatch a bubbling change 24 * event so this component picks it up: 25 * `input.dispatchEvent(new Event('change', { bubbles: true }))` 26 */ 27 class ValidationEnhancer extends HTMLElement { 28 // Class applied to a valid input, default `valid` 29 get validClass() { 30 return this.getAttribute("valid-class") || "valid"; 31 } 32 // Class applied to an invalid input, default `invalid` 33 get invalidClass() { 34 return this.getAttribute("invalid-class") || "invalid"; 35 } 36 // The name of the attribute of the input element that holds the id of the error message element 37 // default `aria-errormessage`, eg `<input aria-errormessage='err' /><div id='err' />` 38 get messageTargetAttr() { 39 return this.getAttribute("message-target-attr") || "aria-errormessage"; 40 } 41 // Prefix for attributes containing custom validation messages, default `validation` 42 get messagePrefix() { 43 return this.getAttribute("message-prefix") || "validation"; 44 } 45 // The aria-live urgency of error messages, default `polite` 46 get messageAriaLive() { 47 return this.getAttribute("message-aria-live") || "polite"; 48 } 49 // Scroll-into-view options for auto-scrolling to the first invalid input 50 // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView 51 // defaults `scroll-align-to-top` and `smooth` 52 get scrollIntoViewOptions() { 53 const alignToTop = this.getAttribute("scroll-align-to-top"); 54 if (alignToTop !== null) { 55 return alignToTop !== "false"; 56 } 57 const options = { 58 behavior: this.getAttribute("scroll-behavior") || "smooth", 59 }; 60 const block = this.getAttribute("scroll-block"); 61 if (block) 62 options.block = block; 63 const inline = this.getAttribute("scroll-inline"); 64 if (inline) 65 options.inline = inline; 66 return options; 67 } 68 // Tracks changes to the element's children 69 mutationObserver = null; 70 constructor() { 71 super(); 72 // Bind event listeners 73 this.handleChangeAndFocusOut = this.handleChangeAndFocusOut.bind(this); 74 this.handleKeyUp = this.handleKeyUp.bind(this); 75 this.handleSubmit = this.handleSubmit.bind(this); 76 this.handleMutation = this.handleMutation.bind(this); 77 } 78 connectedCallback() { 79 // Register event listeners 80 this.addEventListener("keyup", this.handleKeyUp); 81 this.addEventListener("change", this.handleChangeAndFocusOut); 82 this.addEventListener("focusout", this.handleChangeAndFocusOut); 83 this.addEventListener("submit", this.handleSubmit); 84 const inputs = this.querySelectorAll(`[${this.messageTargetAttr}]`); 85 this.setupValidationTargetElements(Array.from(inputs)); 86 const forms = this.querySelectorAll("form"); 87 this.setupForms(Array.from(forms)); 88 this.mutationObserver = new MutationObserver(this.handleMutation); 89 this.mutationObserver.observe(this, { 90 childList: true, 91 subtree: true, 92 }); 93 } 94 disconnectedCallback() { 95 this.removeEventListener("keyup", this.handleKeyUp); 96 this.removeEventListener("focusout", this.handleChangeAndFocusOut); 97 this.removeEventListener("change", this.handleChangeAndFocusOut); 98 this.removeEventListener("submit", this.handleSubmit); 99 this.mutationObserver?.disconnect(); 100 this.mutationObserver = null; 101 } 102 /** 103 * On mutation, add novalidate to forms and aria-live to validation message target elements 104 * @param mutations Array of MutationRecord from the MutationObserver 105 */ 106 handleMutation(mutations) { 107 mutations.forEach((mutation) => { 108 const nodes = Array.from(mutation.addedNodes).filter((node) => node instanceof Element); 109 this.setupValidationTargetElements(nodes); 110 const forms = nodes.filter((node) => node instanceof HTMLFormElement); 111 this.setupForms(forms); 112 }); 113 } 114 /** 115 * Add novalidate to a list of forms, disabling browser validation 116 * @param forms Array of form elements to set novalidate on 117 */ 118 setupForms(forms) { 119 forms.forEach((form) => (form.noValidate = true)); 120 } 121 /** 122 * Add aria-live attribute to an element if it's referenced by an input's message target attribute 123 * @param element The potential message target element to add aria-live to 124 */ 125 ensureAriaLive(element) { 126 if (element.id && 127 this.querySelector(`[${this.messageTargetAttr}="${CSS.escape(element.id)}"]`)) { 128 if (!element.hasAttribute("aria-live")) { 129 element.setAttribute("aria-live", this.messageAriaLive); 130 } 131 } 132 } 133 /** 134 * Add aria-live attribute to message target elements for inputs that lack it 135 * @param elements Array of input elements to check for message targets 136 */ 137 setupValidationTargetElements(elements) { 138 elements.forEach((element) => { 139 const validationMessageTargetElement = this.getValidationMessageTargetElement(element); 140 if (validationMessageTargetElement) { 141 if (!validationMessageTargetElement.hasAttribute("aria-live")) { 142 validationMessageTargetElement.setAttribute("aria-live", this.messageAriaLive); 143 } 144 } 145 }); 146 } 147 /** 148 * Run polite validation for the targeted input whenever the user keys up 149 * @param event The keyup event from the input 150 */ 151 handleKeyUp(event) { 152 const input = event.target; 153 if (input) { 154 this.validateInputPolitely(input); 155 } 156 } 157 /** 158 * Run validation for the targeted input whenever its value changes or it loses focus 159 * @param event The change or focusout event from the input 160 */ 161 handleChangeAndFocusOut(event) { 162 const input = event.target; 163 if (this.isValidatableInput(input)) { 164 this.validateInput(input); 165 } 166 } 167 /** 168 * Validate the whole form, and prevent submission if it's invalid 169 * @param event The submit event from the form 170 */ 171 handleSubmit(event) { 172 const form = event.target; 173 if (!form) { 174 return; 175 } 176 const valid = this.validateAllInputs(form); 177 if (!valid) { 178 event.preventDefault(); 179 event.stopPropagation(); 180 } 181 } 182 /** 183 * Check a single input's validity. Toggles `.valid` / `.invalid` 184 * classes and updates the associated message element. 185 * @param input The input element to validate 186 * @returns boolean, whether the input has passed validation 187 */ 188 validateInput(input) { 189 if (!this.isValidatableInput(input)) { 190 return true; 191 } 192 if (input.checkValidity()) { 193 this.makeInputValid(input); 194 return true; 195 } 196 else { 197 this.makeInputInvalid(input); 198 return false; 199 } 200 } 201 /** 202 * Validates an input, and may apply a valid class, but never applies the invalid class. 203 * This is for use while the user is typing - it avoids that "nagging" sensation of the 204 * validation turning red all the time. 205 * @param input The input element to validate 206 * @returns boolean, whether the input has passed validation 207 */ 208 validateInputPolitely(input) { 209 if (!this.isValidatableInput(input)) { 210 return true; 211 } 212 if (input.checkValidity()) { 213 this.makeInputValid(input); 214 return true; 215 } 216 this.removeInputValidity(input); 217 return false; 218 } 219 /** 220 * Walk every input owned by the form, validate it, and scroll the 221 * first invalid one into view. Inputs without an `aria-errormessage` 222 * target fall back to the browser's native reporting. 223 * @param form The form element to validate 224 * @returns boolean, whether all inputs have passed validation 225 */ 226 validateAllInputs(form) { 227 let scrolledToFirst = false; 228 const inputs = this.getValidatableInputs(form); 229 let formIsValid = true; 230 for (const input of inputs) { 231 const isValid = this.validateInput(input); 232 if (!isValid) { 233 formIsValid = false; 234 if (!scrolledToFirst) { 235 scrolledToFirst = true; 236 const scrollTarget = this.getLabel(input) || input; 237 this.scrollToElement(scrollTarget); 238 input.focus(); 239 } 240 } 241 } 242 return formIsValid; 243 } 244 /** 245 * Checks whether an element participates in validation 246 * @param el The element to check 247 * @returns boolean, whether the element has the validation API 248 */ 249 isValidatableInput(el) { 250 return !!el && !(el instanceof HTMLButtonElement) && "checkValidity" in el; 251 } 252 /** 253 * Set classes and attributes on one input to valid. 254 * Hides any validation message. 255 * @param input The input element to mark as valid 256 */ 257 makeInputValid(input) { 258 input.classList.add(this.validClass); 259 input.classList.remove(this.invalidClass); 260 input.removeAttribute("aria-invalid"); 261 this.clearMessage(input); 262 } 263 /** 264 * Set classes and attributes on one input to invalid. 265 * Shows validation message. 266 * @param input The input element to mark as invalid 267 */ 268 makeInputInvalid(input) { 269 input.classList.add(this.invalidClass); 270 input.classList.remove(this.validClass); 271 input.setAttribute("aria-invalid", "true"); 272 this.showMessage(input); 273 } 274 /** 275 * Removes visual validity from an input, but leaves invalidity and aria validity 276 * @param input The input element to remove valid class from 277 */ 278 removeInputValidity(input) { 279 input.classList.remove(this.validClass); 280 } 281 /** 282 * Write the appropriate error string into the input's companion 283 * message element. Prefers a custom message attribute when one exists. 284 * @param input The input element to show validation message for 285 */ 286 showMessage(input) { 287 const textContent = this.getCustomMessage(input) || input.validationMessage; 288 const validationMessageTargetElement = this.getValidationMessageTargetElement(input); 289 if (validationMessageTargetElement) { 290 validationMessageTargetElement.textContent = textContent; 291 } 292 } 293 /** 294 * Empties the companion message element for a given input. 295 * @param input The input element to clear validation message for 296 */ 297 clearMessage(input) { 298 const validationMessageTargetElement = this.getValidationMessageTargetElement(input); 299 if (validationMessageTargetElement) { 300 validationMessageTargetElement.textContent = ""; 301 } 302 } 303 /** 304 * Resolve the element pointed to by the input's message target 305 * attribute (defaults to `aria-errormessage`), or `null` if the 306 * attribute is missing / the target doesn't exist. 307 * @param input The input element to find the message target for 308 * @returns The message target element, or null if not found 309 */ 310 getValidationMessageTargetElement(input) { 311 const targetId = input.getAttribute(this.messageTargetAttr); 312 if (!targetId) { 313 return null; 314 } 315 return this.querySelector(`#${CSS.escape(targetId)}`) || null; 316 } 317 /** 318 * Validity keys checked in spec order to locate a matching 319 * override attribute on the input. 320 */ 321 validityKeys = [ 322 "valueMissing", 323 "typeMismatch", 324 "patternMismatch", 325 "tooLong", 326 "tooShort", 327 "rangeUnderflow", 328 "rangeOverflow", 329 "stepMismatch", 330 "badInput", 331 ]; 332 /** 333 * Look up a developer-supplied message for the first failing 334 * validity key. Returns `null` when no override is present or 335 * the input is completely valid. 336 * @param input The input element to get custom message for 337 * @returns The custom validation message, or null if not found 338 */ 339 getCustomMessage(input) { 340 if (!input.validity) { 341 return null; 342 } 343 const prefix = this.messagePrefix; 344 for (const key of this.validityKeys) { 345 if (key in input.validity && input.validity[key]) { 346 return (input.getAttribute(`${prefix}-${key}`) ?? input.getAttribute(`data-${prefix}-${key}`)); 347 } 348 } 349 return null; 350 } 351 /** 352 * Gets all validatable inputs on the form 353 * @param form The form element to get inputs from 354 * @returns Array of validatable input elements 355 */ 356 getValidatableInputs(form) { 357 return Array.from(form.elements ?? []).filter(this.isValidatableInput); 358 } 359 /** 360 * Scrolls an element into view, respecting the configured scroll options. 361 * @param element The element to scroll into view 362 */ 363 scrollToElement(element) { 364 const containerSelector = this.getAttribute("scroll-container"); 365 if (containerSelector) { 366 const container = document.querySelector(containerSelector); 367 if (container) { 368 const containerRect = container.getBoundingClientRect(); 369 const elementRect = element.getBoundingClientRect(); 370 const options = this.scrollIntoViewOptions; 371 const behavior = typeof options === "boolean" ? "auto" : options.behavior || "auto"; 372 container.scrollTo({ 373 top: container.scrollTop + (elementRect.top - containerRect.top), 374 left: container.scrollLeft + (elementRect.left - containerRect.left), 375 behavior, 376 }); 377 return; 378 } 379 } 380 element.scrollIntoView(this.scrollIntoViewOptions); 381 } 382 /** 383 * Gets an input's label element 384 * @param input The input element to find the label for 385 * @returns The label element, or null if not found 386 */ 387 getLabel(input) { 388 if (input.id) { 389 return document.querySelector(`label[for=${CSS.escape(input.id)}]`); 390 } 391 return null; 392 } 393 } 394 customElements.define("validation-enhancer", ValidationEnhancer); 395 396 export { ValidationEnhancer };