UNPKG

element-internals-polyfill

Version:
303 lines (302 loc) 11.3 kB
import { hiddenInputMap, formsMap, formElementsMap, internalsMap, } from "./maps.js"; import { disabledOrNameObserver, disabledOrNameObserverConfig, } from "./mutation-observers.js"; /** * Set attribute if its value differs from existing one. * * In comparison to other attribute modification methods (removeAttribute and * toggleAttribute), setAttribute always triggers attributeChangedCallback * even if the actual value has not changed. * * This polyfill relies heavily on attributes to pass aria information to * screen readers. This behaviour differs from native implementation which does * not change attributes. * * To limit this difference we only set attribute value when it is different * from the current state. * * @param {ICustomElement | Element} ref - The custom element instance * @param {string} name - The attribute name * @param {string} value - The attribute value * @returns */ export const setAttribute = (ref, name, value) => { if (ref.getAttribute(name) === value) { return; } ref.setAttribute(name, value); }; /** * Toggle's the disabled state (attributes & callback) on the given element * @param {HTMLElement} ref - The custom element instance * @param {boolean} disabled - The disabled state */ export const setDisabled = (ref, disabled) => { ref.toggleAttribute("internals-disabled", disabled); if (disabled) { setAttribute(ref, "aria-disabled", "true"); } else { ref.removeAttribute("aria-disabled"); } if (ref.formDisabledCallback) { ref.formDisabledCallback.apply(ref, [disabled]); } }; /** * Removes all hidden inputs for the given element internals instance * @param {ElementInternals} internals - The element internals instance * @return {void} */ export const removeHiddenInputs = (internals) => { const hiddenInputs = hiddenInputMap.get(internals); hiddenInputs.forEach((hiddenInput) => { hiddenInput.remove(); }); hiddenInputMap.set(internals, []); }; /** * Creates a hidden input for the given ref * @param {HTMLElement} ref - The element to watch * @param {ElementInternals} internals - The element internals instance for the ref * @return {HTMLInputElement} The hidden input */ export const createHiddenInput = (ref, internals) => { const input = document.createElement("input"); input.type = "hidden"; input.name = ref.getAttribute("name"); ref.after(input); hiddenInputMap.get(internals).push(input); return input; }; /** * Initialize a ref by setting up an attribute observe on it * looking for changes to disabled * @param {HTMLElement} ref - The element to watch * @param {ElementInternals} internals - The element internals instance for the ref * @return {void} */ export const initRef = (ref, internals) => { hiddenInputMap.set(internals, []); disabledOrNameObserver.observe?.(ref, disabledOrNameObserverConfig); }; /** * Set up labels for the ref * @param {HTMLElement} ref - The ref to add labels to * @param {NodeList} labels - A list of the labels * @return {void} */ export const initLabels = (ref, labels) => { if (labels.length) { const labelList = Array.from(labels); labelList.forEach((label) => label.addEventListener("click", ref.click.bind(ref))); const [firstLabel] = labelList; let firstLabelId = firstLabel.id; if (!firstLabel.id) { firstLabelId = `${firstLabel.htmlFor}_Label`; firstLabel.id = firstLabelId; } setAttribute(ref, "aria-labelledby", firstLabelId); } }; /** * Sets the internals-valid and internals-invalid attributes * based on form validity. * @param {HTMLFormElement} - The target form * @return {void} */ export const setFormValidity = (form) => { const nativeControlValidity = Array.from(form.elements) .filter((element) => !element.tagName.includes("-") && element.validity) .map((element) => element.validity.valid); const polyfilledElements = formElementsMap.get(form) || []; const polyfilledValidity = Array.from(polyfilledElements) .filter((control) => control.isConnected) .map((control) => internalsMap.get(control).validity.valid); const hasInvalid = [...nativeControlValidity, ...polyfilledValidity].includes(false); form.toggleAttribute("internals-invalid", hasInvalid); form.toggleAttribute("internals-valid", !hasInvalid); }; /** * The global form input callback. Updates the form's validity * attributes on input. * @param {Event} - The form input event * @return {void} */ export const formInputCallback = (event) => { setFormValidity(findParentForm(event.target)); }; /** * The global form change callback. Updates the form's validity * attributes on change. * @param {Event} - The form change event * @return {void} */ export const formChangeCallback = (event) => { setFormValidity(findParentForm(event.target)); }; /** * The global form submit callback. We need to cancel any submission * if a nested internals is invalid. * @param {HTMLFormElement} - The form element * @return {void} */ export const wireSubmitLogic = (form) => { const submitButtonSelector = [ "button[type=submit]", "input[type=submit]", "button:not([type])", ] .map((sel) => `${sel}:not([disabled])`) .map((sel) => `${sel}:not([form])${form.id ? `,${sel}[form='${form.id}']` : ""}`) .join(","); form.addEventListener("click", (event) => { const target = event.target; if (target.closest(submitButtonSelector)) { // validate const elements = formElementsMap.get(form); /** * If this form does not validate then we're done */ if (form.noValidate) { return; } /** If the Set has items, continue */ if (elements.size) { const nodes = Array.from(elements); /** Check the internals.checkValidity() of all nodes */ const validityList = nodes.reverse().map((node) => { const internals = internalsMap.get(node); return internals.reportValidity(); }); /** If any node is false, stop the event */ if (validityList.includes(false)) { event.preventDefault(); } } } }); }; /** * The global form reset callback. This will loop over added * inputs and call formResetCallback if applicable * @return {void} */ export const formResetCallback = (event) => { /** Get the Set of elements attached to this form */ const elements = formElementsMap.get(event.target); /** Some forms won't contain form associated custom elements */ if (elements && elements.size) { /** Loop over the elements and call formResetCallback if applicable */ elements.forEach((element) => { if (element.constructor.formAssociated && element.formResetCallback) { element.formResetCallback.apply(element); } }); } }; /** * Initialize the form. We will need to add submit and reset listeners * if they don't already exist. If they do, just add the new ref to the form. * @param {HTMLElement} ref - The element ref that includes internals * @param {HTMLFormElement} form - The form the ref belongs to * @param {ElementInternals} internals - The internals for ref * @return {void} */ export const initForm = (ref, form, internals) => { if (form) { /** This will be a WeakMap<HTMLFormElement, Set<HTMLElement> */ const formElements = formElementsMap.get(form); if (formElements) { /** If formElements exists, add to it */ formElements.add(ref); } else { /** If formElements doesn't exist, create it and add to it */ const initSet = new Set(); initSet.add(ref); formElementsMap.set(form, initSet); /** Add listeners to emulate validation and reset behavior */ wireSubmitLogic(form); form.addEventListener("reset", formResetCallback); form.addEventListener("input", formInputCallback); form.addEventListener("change", formChangeCallback); } formsMap.set(form, { ref, internals }); /** Call formAssociatedCallback if applicable */ if (ref.constructor["formAssociated"] && ref.formAssociatedCallback) { setTimeout(() => { ref.formAssociatedCallback.apply(ref, [form]); }, 0); } setFormValidity(form); } }; /** * Recursively look for an element's parent form * @param {Element} elem - The element to look for a parent form * @return {HTMLFormElement|null} - The parent form, if one exists */ export const findParentForm = (elem) => { let parent = elem.parentNode; if (parent && parent.tagName !== "FORM") { parent = findParentForm(parent); } return parent; }; /** * Throw an error if the element ref is not form associated * @param ref {HTMLElement} - The element to check if it is form associated * @param message {string} - The error message to throw * @param ErrorType {any} - The error type to throw, defaults to DOMException */ export const throwIfNotFormAssociated = (ref, message, ErrorType = DOMException) => { if (!ref.constructor["formAssociated"]) { throw new ErrorType(message); } }; /** * Called for each HTMLFormElement.checkValidity|reportValidity * will loop over a form's added components and call the respective * method modifying the default return value if needed * @param form {HTMLFormElement} - The form element to run the method on * @param returnValue {boolean} - The initial result of the original method * @param method {'checkValidity'|'reportValidity'} - The original method * @returns {boolean} The form's validity state */ export const overrideFormMethod = (form, returnValue, method) => { const elements = formElementsMap.get(form); /** Some forms won't contain form associated custom elements */ if (elements && elements.size) { elements.forEach((element) => { const internals = internalsMap.get(element); const valid = internals[method](); if (!valid) { returnValue = false; } }); } return returnValue; }; /** * Will upgrade an ElementInternals instance by initializing the * instance's form and labels. This is called when the element is * either constructed or appended from a DocumentFragment * @param ref {HTMLElement} - The custom element to upgrade */ export const upgradeInternals = (ref) => { if (ref.constructor["formAssociated"]) { const internals = internalsMap.get(ref); const { labels, form } = internals; initLabels(ref, labels); initForm(ref, form, internals); } }; /** * Check to see if MutationObserver exists in the current * execution context. Will likely return false on the server * @returns {boolean} */ export function mutationObserverExists() { return typeof MutationObserver !== "undefined"; }