simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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 };