@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
545 lines (474 loc) • 13.4 kB
JavaScript
/**
* 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);