/** * Copyright © schukai GmbH 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 schukai GmbH. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../constants.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE, ATTRIBUTE_ROLE, } from "../../dom/constants.mjs"; import { assembleMethodSymbol, attributeObserverSymbol, CustomElement, initMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { isArray, isString } from "../../types/is.mjs"; import { ATTRIBUTE_FORM_RELOAD, ATTRIBUTE_FORM_URL } from "./constants.mjs"; import { loadAndAssignContent } from "./util/fetch.mjs"; export { Template }; /** * @private * @type {symbol} */ const intersectionObserverWasInitialized = Symbol("wasInitialized"); /** * A Template control is a control that can be used to load content from a URL and display it in the ShadowRoot. * * @fragments /fragments/components/form/template/ * * @example /examples/components/form/template-simple * * @since 1.11.0 * @copyright schukai GmbH * @summary A template control * @fires monster-fetched */ class Template extends CustomElement { /** * This method is called by the `instanceof` operator. * @return {symbol} * @since 2.1.0 */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/form/template"); } /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {string} url=undefined * @property {string} reload=undefined currently the only value defined is `onshow`. Currently the only value defined is onshow. this removes the IntersectionObserver. this means that the content is only loaded once. reloading of the content does not occur. * @property {Monster.Components.Form.Processor[]} processors * @property {Object} fetch Fetch [see Using Fetch mozilla.org](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) * @property {String} fetch.redirect=error * @property {String} fetch.method=GET * @property {String} fetch.mode=same-origin * @property {String} fetch.credentials=same-origin * @property {Object} fetch.headers={"accept":"text/html"}} */ get defaults() { return Object.assign( {}, super.defaults, { templates: { main: getTemplate(), }, url: undefined, reload: undefined, processors: [], fetch: { redirect: "error", method: "GET", mode: "same-origin", credentials: "same-origin", headers: { accept: "text/html", }, }, }, initOptionsFromArguments.call(this), ); } /** * This method determines which attributes are to be monitored by `attributeChangedCallback()`. * * @return {string[]} */ static get observedAttributes() { const list = super.observedAttributes; list.push(ATTRIBUTE_FORM_URL); return list; } /** * */ [initMethodSymbol]() { super[initMethodSymbol](); // data-monster-options this[attributeObserverSymbol][ATTRIBUTE_FORM_URL] = (url) => { if (this.hasAttribute(ATTRIBUTE_FORM_URL)) { this.setOption("url", new URL(url, document.location).toString()); } else { this.setOption("url", undefined); } }; } /** * This method is called internal and should not be called directly. * @throws {Error} missing default slot * @throws {Error} no shadow-root is defined * @throws {Error} missing url * @throws {Error} we won't be able to read the data * @throws {Error} request failed * @throws {Error} not found * @throws {Error} undefined status or type * @fires monster-fetched * @return {Monster.Components.Form.Form} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initIntersectionObserver.call(this); } /** * This method is called internal and should not be called directly. * * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return []; } /** * This method is called internal and should not be called directly. * * @return {string} */ static getTag() { return "monster-template"; } /** * load content from url * * It is important to know that with this function the loading is executed * directly. it is loaded as well when the element is not visible. * * @param {string|undefined} url */ fetch(url) { if (isString(url) || url instanceof URL) { this.setAttribute(ATTRIBUTE_FORM_URL, `${url}`); } return loadContent.call(this); } } /** * @typedef {Object} Processor * @property {String} destination * @property {String} source * @since 1.11.8 */ /** * This attribute can be used to pass a URL to this select. * * ``` * <monster-select data-monster-url="https://example.com/"></monster-select> * ``` * * @private * @return {object} */ function initOptionsFromArguments() { const options = {}; const url = this.getAttribute(ATTRIBUTE_FORM_URL); if (isString(url)) { options["url"] = new URL(url, document.location).toString(); } if (this.hasAttribute(ATTRIBUTE_FORM_RELOAD)) { options["reload"] = this.getAttribute(ATTRIBUTE_FORM_RELOAD).toLowerCase(); } return options; } /** * @private * @throws {Error} missing default slot * @throws {Error} no shadow-root is defined * @throws {Error} missing url * @throws {Error} we won't be able to read the data * @throws {Error} request failed * @throws {Error} not found * @throws {Error} undefined status or type * @fires monster-fetched */ function initIntersectionObserver() { if (this[intersectionObserverWasInitialized] === true) { return; } this[intersectionObserverWasInitialized] = true; const options = { threshold: [0.5], }; const callback = (entries, observer) => { for (const [, entry] of entries.entries()) { if (entry.isIntersecting === true) { if (this.getOption("reload") === "onshow") { observer.disconnect(); } try { loadContent.call(this); } catch (e) { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.toString()); } } } }; const observer = new IntersectionObserver(callback, options); observer.observe(this); } /** * @private * @throws {Error} missing default slot * @throws {Error} no shadow-root is defined * @throws {Error} missing url * @throws {Error} we won't be able to read the data * @throws {Error} request failed * @throws {Error} not found * @throws {Error} undefined status or type * @throws {Error} client error * @throws {Error} undefined status or type * @throws {TypeError} value is not an instance of * @throws {TypeError} value is not a string * @fires monster-fetched */ function loadContent() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } const url = this.getOption("url", undefined); if (!isString(url) || url === "") { throw new Error("missing url"); } const options = this.getOption("fetch", {}); const defaultSlot = this.shadowRoot.querySelector("slot[name=default]"); if (!(defaultSlot instanceof HTMLElement)) { throw new Error("missing default slot"); } defaultSlot.style.display = "block"; let container = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=container]`, ); if (!(container instanceof HTMLElement)) { container = document.createElement("div"); container.style.display = "none"; container.setAttribute(ATTRIBUTE_ROLE, "container"); this.shadowRoot.appendChild(container); } loadAndAssignContent(container, url, options) .then(() => { defaultSlot.style.display = "none"; runProcessors.call(this); }) .catch((e) => { throw e; }); } /** * @private * @return {runProcessors} */ function runProcessors() { const processors = this.getOption("processors"); if (!isArray(processors)) return; for (const [, processor] of processors.entries()) { const source = processor?.source; const destination = processor?.destination; if (isString(source) && isString(destination)) { const sourceNode = this.shadowRoot.querySelector(source); const destinationNode = document.querySelector(destination); if ( sourceNode instanceof HTMLElement && destinationNode instanceof HTMLElement ) { destinationNode.innerHTML = sourceNode.cloneNode(true).innerHTML; } } } return this; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <slot name="default"></slot> `; } registerCustomElement(Template);