UNPKG

jspanel4

Version:

A JavaScript library to create highly configurable multifunctional floating panels that can also be used as modal, tooltip, hint or contextmenu

559 lines (542 loc) 19.1 kB
/** * jsPanel - A JavaScript library to create highly configurable multifunctional floating panels that can also be used as modal, tooltip, hint or contextmenu * @version v4.16.1 * @homepage https://jspanel.de/ * @license MIT * @author Stefan Sträßer - info@jspanel.de * @author of dialog extension: Michael Daumling - michael@terrapinlogo.com * @github https://github.com/Flyer53/jsPanel4.git */ import {jsPanel} from '../../jspanel.js'; if (!jsPanel.dialog) { jsPanel.dialog = { version: '1.0.0', date: '2022-04-25 10:00', defaults: { theme: "none", header: false, position: {my:'center-top', at:'center-top', offsetY: 30}, contentSize: "auto", onwindowresize: true, closeOnEscape: false, closeOnBackdrop: false, oninitialize: [] }, // You can wrap dialogs into this <template> tag to prevent them from rendering dialogTemplateId: "#dialogs", // CSS classes css: { // primary button primaryBtn: "blue", // all other buttons otherBtn: "white", // the button bar buttonBar: "buttonbar", // the input field in the prompt() alert promptInput: "prompt-input" }, // The vertical offset for child dialogs offsetY: 30, /** * Display a modal dialog. The html may either be a selector (whose content * is deep-cloned), a HTML string, a HTMLElement (deep-cloned), or a simple * string, which is wrapped in a <span> tag. Since existing HTML is cloned, * you should not use IDs in that HTML, because these IDs will occur twice * as long as the dialog exists. * * You can also wrap your dialogs in a <template> tag, which keeps them from * being rendered. The ID of that tag is stored in jsPanel.dialog.templateId * (the default is "dialogs"). When you do this, you are also free to use * HTML IDs if required. * * To initialize the dialog, use either the "callback" option, which is called * synchronously during the creation of the panel, or use the "oninitialize" * option, which takes a function or an array of functions as usual. This * callback is called after the panel has been created, so it is safe to e.g. * display alerts inside the function. * * The modal's value is the dialog value (see makeDialog() below). * * @param {string|HTMLElement} html (a selector, a HTML string, or the HTML itself) * @param {Object} options * @returns {*} any value returned by a handler, or the value of the last clicked element */ async modal(html, options = {}) { html = this.helpers.getHTML(html); options = Object.assign({}, this.defaults, options); options.content = html; const prev = this.active; if (prev) { options.position = Object.assign({}, options.position); options.position.offsetY = prev.options.position.offsetY + this.offsetY; } options.css = { panel: "jsPanel-dialog" }; for (let cls of ["dialog-sm", "dialog-md", "dialog-lg", "dialog-xl"]) { if (!html.classList.contains(cls)) continue; html.classList.remove(cls); options.css.panel += " " + cls; break; } // Make sure that the callback function is an array if (!options.callback) options.callback = []; else if (typeof options.callback === "function") options.callback = [options.callback]; options.callback.unshift(panel => { panel.makeDialog(); if (this.helpers.all.length) // Move a previous modal dialog into the background this.helpers.all[0].classList.add("background"); this.helpers.all.unshift(panel); }); document.addEventListener("jspanelloaded", ev => { if (ev.panel.options.oninitialize) jsPanel.processCallbacks(ev.panel, ev.panel.options.oninitialize, "every"); }, { once: true }); const panel = jsPanel.modal.create(options); panel.resize({height: 'auto'}); return new Promise(resolve => { panel.options.onclosed.push(panel => { this.helpers.all.shift(); if (this.helpers.all.length) this.helpers.all[0].classList.remove("background"); const value = panel.dialog.value; panel.dialog.value = undefined; resolve(value); }); }); }, /** * Return the currently active modal dialog. */ get active() { return this.helpers.all[0]; }, /** * Return the number of open dialogs. */ get depth() { return this.helpers.all.length; }, /** * Force-close all active dialogs. */ closeAll() { while (this.helpers.all.length) { const len = this.helpers.all.length; this.helpers.all[0].close(); if (this.helpers.all.length === len) // close refused return; } // sometimes, there seems to bea leftover backdrop for (let el of document.querySelectorAll(".jsPanel-modal.background")) el.remove(); }, /** * Display a message box with the given list of buttons. * If there is no button, create an OK button. The buttons array * is either a list of labels, or objects with these properties: * label - the label * value - the value for panel.dialog.value when clicked (opt) * name - the name of the button if they are accessed ba name (opt) * css - any CSS classes for the button * * @param {string|Node} msg (can be a string, a HTML string, or HTML) * @param {Array} buttons * @param {Object} options * @returns {string} the button value */ async alert(msg, buttons = ["OK"], options = {}) { msg = this.helpers.getHTML(msg); if (!buttons.length) buttons.push("OK"); msg.append(this.helpers.buttonBar(buttons)); return this.modal(msg, options); }, /** * Display a Confirm (Yes/No) box. If no is true, No is set as the default. * * @param {string} msg the message * @param {bool} no if true, No is preselected * @param {Array} moreButtons any extra buttons (see jsPanel.dialog.alert()) * @param {Object} options * @returns {bool} */ async confirm(msg, no = false, moreButtons = [], options = {}) { const y = { label: "Yes", value: "true" }; const n = { label: "No", value: "false" }; let btns = no ? [n, y] : [y, n]; btns = btns.concat(moreButtons); return await this.alert(msg, btns, options) === "true"; }, /** * Display a prompt box with a message and an input field, with an optional * preset. Either returns the content of the input field or null. * @param {String} msg * @param {String} preset * @param {Object} options * @returns {string|null} */ async prompt(msg, preset = "", options = {}) { msg = this.helpers.getHTML(`<div>${msg}</div>`); const div = document.createElement("div"); if (msg instanceof DocumentFragment) { for (let el = msg.firstChild; el; el = el.nextSibling) div.append(msg); } else div.append(msg); const input = jsPanel.strToHtml(`<input name="input" type="text" class="${this.css.promptInput}" value="${preset}"/>`); div.append(input.firstElementChild); div.append(this.helpers.buttonBar(["OK", "Cancel"])); options = Object.assign({ onclick_OK: panel => panel.dialog.value = panel.dialog.values.input, onclick_Cancel: async panel => panel.dialog.value = null }, options); return await this.modal(div, options); }, helpers: { // All open modal dialogs all: [], /** * Create HTML for the given buttons array. Each element is an object with * label and value. The buttons are dismiss buttons. The only button (or * the second button) is marked as the Cancel button that acts on the Esc * key. We also have the "css" property that, optionally, defines button * css classes, and a "name" property if the button should be named. * * If the array element is just a string, name and value are set to that string. * * @param {Array} buttons * @returns {HTMLElement} */ buttonBar(buttons) { const div = document.createElement("div"); div.classList.add(jsPanel.dialog.css.buttonBar); let html = ""; for (let [i, btn] of buttons.map(b => (typeof b === "string") ? { label:b,name:b,value:b} : b).entries()) { let {label, value, css, name} = btn; const classDefs = jsPanel.dialog.css; if (!css) css = i ? classDefs.otherBtn : classDefs.primaryBtn; let cancel = ""; name = name ? `name="${name}"` : ""; switch (i) { case 0: if (buttons.length === 1) cancel = " data-cancel"; break; case 1: cancel = " data-cancel"; break; } html += `<button data-dismiss ${name} ${cancel} class="${css}" value="${value}">${label}</button>`; } div.innerHTML = html; return div; }, /** * Get the HTML to display - see modal() above and make sure it is visible. * @param {Node|string} html * @returns {Node} */ getHTML(html) { if (!(html instanceof Node)) { html = html.toString().trim(); try { let el = document.querySelector(html); if (!el) { const tpl = document.querySelector(jsPanel.dialog.dialogTemplateId); if (tpl) el = tpl.content.querySelector(html); } if (el) html = el.cloneNode(true); else html = jsPanel.strToHtml("<span>" + html + "</span>"); } catch (e) { html = jsPanel.strToHtml(html.toString().trim()); } } if (html instanceof DocumentFragment) { // convert a DocumentFragment to a div, because the former cannot have CSS let div = document.createElement("div"); for (let node of html.childNodes) div.append(node); return div; } if (html instanceof HTMLElement) // ensure its visibility html.style.display = ""; return html; }, /** * Get the value of an element. * * The values are: * radio buttons: the value of the selected button * checkboxes: true or false * text etc: the text * elements with a "value" property: the value of that property * everything else: the inner HTML * * @param {jsPanel} panel * @param {HTMLElement} element * @param {bool} returnHTML - false to return undefined for no value */ getValue(panel, el, returnHTML = true) { switch (el.getAttribute("type")) { case "radio": const name = el.getAttribute("name"); const radio = panel.querySelector(`[name="${name}"]:checked`); return radio ? radio.value : ""; case "checkbox": return el.checked; default: if ("value" in el) return el.value; else if (el.hasAttribute("value")) return el.getAttribute("value"); else if (returnHTML) return el.innerHTML; } }, /** * Set the value of an element. * * The values are: * radio buttons: the value of the selected button * checkboxes: true or false * text etc: the text * elements with a "value" property: the value of that property * everything else: the inner HTML (lets you set content) * * @param {jsPanel} panel * @param {HTMLElement} element * @param {*} value */ setValue(panel, el, value) { switch(el.getAttribute("type")) { case "radio": const name = el.getAttribute("name"); const radio = panel.querySelector(`[name="${name}"][value="${value}"]`); if (radio) radio.checked = true; break; case "checkbox": el.checked = value; break; default: if ("value" in el) el.value = value; else if (el.hasAttribute("value")) el.setAttribute("value", value.toString()); else el.innerHTML = value.toString(); } } } }; document.addEventListener("click", ev => { // handle all clicks on elements without a "name" property; // elements with a "name" property are handled in jsPanel.makeDialog() let el = ev.target.closest("[data-dismiss]"); if (!el) return; let panel = el.closest(".jsPanel"); if (!panel) return; if (el.click) el.click(); if (panel.parentElement) panel.close(); }); /* window.addEventListener("resize", ev => { for (let panel of jsPanel.dialog.helpers.all) panel.reposition(); }); */ jsPanel.extend({ /** * Extend any jsPanel to act as a dialog. * * Elements of this panel can have several additional attributes: * * The "name" attribute is well-known from forms. Actually, you can set this * attribute at any element. Elements with a "name" attribute have * several advantages: * * The panel property "dialog.elements" refers any elements with a "name" * attribute. For example, if you have a textfield with name="user", you * can access that element as panel.dialog.elements.user. If multiple elements * share the same value, the value of this property is a NodeList. * * The panel property "dialog.values" permits direct access to an element's * value. In the above example, panel.dialog.values.user gets and sets the * text of that element. If the element does not have a direct value, you * can define a value with the "value" attribute. If the element does not * have a "value" attribute, you can get and set the element's inner HTML * this way. * * The "data-dismiss" attribute causes the panel to be closed when that element * is clicked. * * The "data-cancel" attribute causes the element to respond to the "Escape" * key. Note that the closeOnEscape option is not used; instead, an own event * listener handles the Escape key, because the closeOnEscape option closes * just any panel that happens to return true in the handler, which is not * desirable in a dialog environment. * * The "data-dblclick" causes the element to respond to a double click as * if it was clicked. * * The panel options can define several handlers for elements. Each handler * starts with either "onclick_" for clicks, or "oninput_" for changes to * a text element, followed by the element's name, like e.g. "oninput_user". * It is fine to call panel.close() in a handler. * * All handlers can set a value by setting panel.dialog.value. If a modal * dialog was created, the return value of the await statement is that * value. If no handler was defined for a clicked element, the element's "value" * attribute is set as the dialog value. Inside a handler, you can, of course, * read panel.dialog.value. */ makeDialog() { this.dialog = { elements: new jsPanelDialogElements(this), values: new jsPanelDialogValues(this), value: undefined }; const cb = async ev => { let el = ev.target.closest("[name]"); ev.stopPropagation(); if (el) { const name = el ? el.getAttribute("name") : ""; const cbName = `on${ev.type}_${name}`; if (this.options[cbName]) jsPanel.processCallbacks(this, this.options[cbName], "every", el, ev); } else el = ev.target; if (el.hasAttribute("data-dismiss") && this.parentElement) { // grab any value if not set yet if (this.dialog.value === undefined) this.dialog.value = jsPanel.dialog.helpers.getValue(this, el, false); this.close(); } }; this.addEventListener("click", cb); this.addEventListener("input", cb); this.addEventListener("dblclick", ev => { const el = this.querySelector("[data-dblclick]"); if (el) { ev.stopPropagation(); el.click(); } }); this.options.closeOnEscape = _ => { // Trigger a data-cancel element if present const el = this.querySelector("[data-cancel]"); if (el) el.click(); return false; } } }); /** * This class acts as a proxy for all dialog elements with either * a "name" attribute or a "data-name" attribute. You can address * such an element with panel.dialog.values.xxx, where xxx is the * element's name. * * The object is enumerable; you may walk over the object with the * usual helpers. If multiple elements have the same name, the getter * returns an array; otherwiese, it returns the HTMLElement or null. */ class jsPanelDialogElements { constructor(panel) { for (let el of panel.querySelectorAll("[name],[data-name]")) { const name = el.getAttribute("name") || el.getAttribute("data-name"); if (this.hasOwnProperty(name)) continue; Object.defineProperty(this, name, { enumerable: true, get() { const result = panel.querySelectorAll(`[name="${name}"],[data-name="${name}"]`); return result.length <= 1 ? result[0] : result; } }); } Object.seal(this); } /** * Retrieve an object with key/value pairs for all elements. This * is more versatile than using a FormData object, because it can * also contain numbers, booleans etc. */ get() { const obj = {}; for (let [name, value] of Object.entries(this)) obj[name] = value; return obj; } } /** * This class acts as a proxy for all dialog elements with * a "name" attribute. You can address such an element's value with * panel.dialog.values.xxx, where xxx is the element's name. * * The values are: * radio buttons: the value of the selected button * checkboxes: true or false * text etc: the text * elements with a "value" property: the value of that property * everything else: the inner HTML (lets you set content) * * The object is enumerable; you may walk over the object with the * usual helpers. * * If multiple elements have the same name, only the first element * is affected, except for radio buttons. */ class jsPanelDialogValues { constructor(panel) { for (let el of panel.querySelectorAll("[name]")) { const name = el.getAttribute("name"); if (this.hasOwnProperty(name)) continue; const type = (el.getAttribute("type") || "").toLowerCase(); Object.defineProperty(this, name, { enumerable: true, get() { return jsPanel.dialog.helpers.getValue(panel, el); }, set(value) { jsPanel.dialog.helpers.setValue(panel, el, value); } }); } Object.seal(this); } /** * Retrieve an object with key/value pairs for all elements. This * is more versatile than using a FormData object, because it can * also contain numbers, booleans etc. */ get() { const obj = {}; for (let [name, value] of Object.entries(this)) obj[name] = value; return obj; } /** * Use an object with key/value pairs to set element values. * * @param {Object} obj */ set(obj) { for (let [name, value] of Object.entries(obj)) this[name] = value; } } }