UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

545 lines (474 loc) 13.4 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import "../datatable/datasource/dom.mjs"; import "../form/field-set.mjs"; import "../form/context-error.mjs"; import { DeadMansSwitch } from "../../util/deadmansswitch.mjs"; import { DataSet } from "../datatable/dataset.mjs"; import { assembleMethodSymbol, registerCustomElement, getSlottedElements, } from "../../dom/customelement.mjs"; import { datasourceLinkedElementSymbol } from "../datatable/util.mjs"; import { FormStyleSheet } from "./stylesheet/form.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { getDocument } from "../../dom/util.mjs"; import { InvalidStyleSheet } from "./stylesheet/invalid.mjs"; import { isObject } from "../../types/is.mjs"; import { ID } from "../../types/id.mjs"; export { Form }; /** * @private * @type {symbol} */ const debounceWriteBackSymbol = Symbol("debounceWriteBack"); /** * @private * @type {symbol} */ const debounceBindSymbol = Symbol("debounceBind"); /** * A form control that can be used to group form elements. * * @fragments /fragments/components/form/form/ * * @example /examples/components/form/form-simple * * @issue https://localhost.alvine.dev:8440/development/issues/closed/281.html * @issue https://localhost.alvine.dev:8440/development/issues/closed/217.html * * @since 1.0.0 * @copyright Volker Schukai * @summary A form control * @fires monster-options-set * @fires monster-selected * @fires monster-change * @fires monster-changed */ class Form extends DataSet { /** * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} classes Class definitions * @property {string} classes.form Form class * @property {Object} writeBack Write back definitions * @property {string[]} writeBack.events Write back events * @property {Object} bind Bind definitions * @property {Object} reportValidity Report validity definitions * @property {string} reportValidity.selector Report validity selector * @property {boolean} features.mutationObserver Mutation observer feature * @property {boolean} features.writeBack Write back feature * @property {boolean} features.bind Bind feature */ get defaults() { const obj = Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, classes: { form: "", }, labels: {}, writeBack: { events: ["keyup", "click", "change", "drop", "touchend", "input"], transformer: null, callbacks: null, }, reportValidity: { selector: "input,select,textarea,monster-select,monster-toggle-switch,monster-password", }, eventProcessing: true, }); obj["features"]["mutationObserver"] = false; obj["features"]["writeBack"] = true; return obj; } /** * * @return {string} */ static getTag() { return "monster-form"; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [FormStyleSheet, InvalidStyleSheet]; } /** * */ [assembleMethodSymbol]() { const selector = this.getOption("datasource.selector"); if (!selector) { this[datasourceLinkedElementSymbol] = getDocument().createElement( "monster-datasource-dom", ); } super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); initDataSourceHandler.call(this); } /** * This method is called when the component is created. * @since 3.70.0 * @return {Promise} */ refresh() { return this.write().then(() => { super.refresh(); return this; }); } /** * Run reportValidation on all child html form controls. * * @since 2.10.0 * @return {boolean} */ reportValidity() { let valid = true; const selector = this.getOption("reportValidity.selector"); const nodes = getSlottedElements.call(this, selector); nodes.forEach((node) => { if (typeof node.reportValidity === "function") { if (node.reportValidity() === false) { valid = false; } } }); return valid; } } /** * @private * @return {initEventHandler} */ function initEventHandler() { this[debounceBindSymbol] = {}; if (this.getOption("features.writeBack") === true) { setTimeout(() => { const events = this.getOption("writeBack.events"); for (const event of events) { this.addEventListener(event, (e) => { const target = e?.target; const targetTag = target?.tagName?.toLowerCase?.(); if (targetTag === "monster-select" && e.type !== "change") { return; } if (e?.target && typeof e.target.setCustomValidity === "function") { e.target.setCustomValidity(""); e.target.removeAttribute("data-monster-validation-error"); } if (this.getOption("logLevel") === "debug") { console.log("monster-form: event triggered write", { element: e.target, event: e.type, }); } if (!this.reportValidity()) { this.classList.add("invalid"); setTimeout(() => { this.classList.remove("invalid"); }, 1000); return; } if (this[debounceWriteBackSymbol] instanceof DeadMansSwitch) { try { this[debounceWriteBackSymbol].touch(); return; } catch (e) { if (e.message !== "has already run") { throw e; } delete this[debounceWriteBackSymbol]; } } this[debounceWriteBackSymbol] = new DeadMansSwitch(200, () => { setTimeout(() => { this.write().catch((e) => { addAttributeToken(this, "error", e.message || `${e}`); }); }, 0); }); }); } }, 0); } return this; } /** * @private */ function initDataSourceHandler() { setTimeout(() => { const datasource = this[datasourceLinkedElementSymbol]; if (!(datasource instanceof HTMLElement)) { return; } datasource.addEventListener("monster-datasource-validation", (event) => { applyValidationErrors.call(this, event?.detail); }); datasource.addEventListener("monster-datasource-fetch", () => { clearValidationErrors.call(this); }); datasource.addEventListener("monster-datasource-fetched", () => { clearValidationErrors.call(this); }); }, 20); } /** * @private */ function clearValidationErrors() { const selector = this.getOption("reportValidity.selector"); const nodes = getSlottedElements.call(this, selector); nodes.forEach((node) => { if (typeof node.setCustomValidity === "function") { node.setCustomValidity(""); } node.removeAttribute?.("data-monster-validation-error"); }); const errors = this.querySelectorAll( "monster-context-error[data-monster-validation-for]", ); errors.forEach((node) => { if (typeof node.resetErrorMessage === "function") { node.resetErrorMessage(); } else { node.removeAttribute("data-monster-validation-for"); } }); } /** * @private * @param {object} detail */ function applyValidationErrors(detail) { if (!detail || !isObject(detail.errors)) { return; } clearValidationErrors.call(this); const datasource = this[datasourceLinkedElementSymbol]; const mapping = datasource?.getOption?.("validation.map") || datasource?.getOption?.("validation")?.map || {}; const labels = this.getOption("labels", {}); const selector = this.getOption("reportValidity.selector"); for (const [key, message] of Object.entries(detail.errors)) { if (!key) { continue; } let bindPath = mapping?.[key]; if (isObject(bindPath)) { continue; } let code = null; let msg = message; if (isObject(message)) { code = message?.code || null; msg = message?.message || ""; } if (code && typeof labels?.[code] === "string") { msg = labels[code]; } let target = null; const candidates = getSlottedElements.call(this, selector); for (const candidate of candidates) { if (!(candidate instanceof HTMLElement)) { continue; } const bind = candidate.getAttribute("data-monster-bind"); const name = candidate.getAttribute("name"); const id = candidate.getAttribute("id"); let matches = false; if (typeof bindPath === "string") { if (bind === bindPath) { matches = true; } else if ( !bindPath.startsWith("path:") && bind === `path:${bindPath}` ) { matches = true; } } if (bind === `path:${key}` || bind === `path:data.${key}`) { matches = true; } if (name === key || id === key) { matches = true; } if (matches) { target = candidate; break; } } if (!target) { continue; } const targetName = target.getAttribute("name"); const targetBind = target.getAttribute("data-monster-bind"); const matchesKey = targetName === key || targetBind === `path:${key}` || targetBind === `path:data.${key}`; const matchesMapped = typeof bindPath === "string" && (targetBind === bindPath || targetBind === `path:${bindPath}`); if (!matchesKey && !matchesMapped) { continue; } if (typeof target.setCustomValidity === "function") { target.setCustomValidity(`${msg}`); } target.setAttribute("data-monster-validation-error", `${msg}`); const errorElement = getOrCreateValidationErrorElement.call( this, target, key, ); if (errorElement && typeof errorElement.setErrorMessage === "function") { errorElement.setErrorMessage(`${msg}`, false); } } } /** * @private * @param {HTMLElement} target * @param {string} key * @return {HTMLElement|null} */ function getOrCreateValidationErrorElement(target, key) { const marker = target.getAttribute("name") || `${key}` || target.getAttribute("id") || `v-${key}`; const label = findLabelForTarget.call(this, target, key); if (!label) { return null; } let errorElement = label.querySelector( `monster-context-error[data-monster-validation-for="${marker}"]`, ); const contextEndContainer = getOrCreateContextEndContainer(label); if (errorElement) { if (errorElement.parentElement !== contextEndContainer) { contextEndContainer.appendChild(errorElement); } errorElement.style.marginInlineStart = ""; return errorElement; } errorElement = document.createElement("monster-context-error"); errorElement.setAttribute("data-monster-validation-for", marker); contextEndContainer.appendChild(errorElement); return errorElement; } /** * @private * @param {HTMLLabelElement} label * @return {HTMLSpanElement} */ function getOrCreateContextEndContainer(label) { let container = label.querySelector('[data-monster-role="context-end"]'); if (container) { return container; } container = document.createElement("span"); container.setAttribute("data-monster-role", "context-end"); container.style.display = "inline-flex"; container.style.alignItems = "center"; container.style.marginInlineStart = "auto"; const contextSelectors = ":scope > monster-context-help," + ":scope > monster-context-error," + ":scope > monster-context-note," + ":scope > monster-context-hint," + ":scope > monster-context-warning," + ":scope > monster-context-info," + ":scope > monster-context-success"; const contextElements = label.querySelectorAll(contextSelectors); for (const contextElement of contextElements) { container.appendChild(contextElement); } label.appendChild(container); return container; } /** * @private * @param {HTMLElement} target * @param {string} key * @return {HTMLLabelElement|null} */ function findLabelForTarget(target, key) { const id = target.getAttribute("id"); if (id) { const labelById = getSlottedElements.call(this, `label[for="${id}"]`); const next = labelById?.values()?.next(); if (next && next.value instanceof HTMLLabelElement) { return next.value; } } const labelKey = target.getAttribute("name") || key; if (labelKey) { const labelByName = getSlottedElements.call( this, `label[data-monster-label="${labelKey}"]`, ); const next = labelByName?.values()?.next(); if (next && next.value instanceof HTMLLabelElement) { return next.value; } } let prev = target.previousElementSibling; while (prev) { if (prev instanceof HTMLLabelElement) { return prev; } prev = prev.previousElementSibling; } return null; } /** * @private * @return {FilterButton} */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } return this; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <form data-monster-attributes="disabled path:disabled | if:true, class path:classes.form" data-monster-role="form" part="form"> <slot data-monster-role="slot"></slot> </form> </div> `; } registerCustomElement(Form);