diff --git a/source/components/form/select.mjs b/source/components/form/select.mjs index d77ba607300295f87731facaf8a05aec0b19863d..631a3ff4e127923b72959aeb1d025981ad9a32d2 100644 --- a/source/components/form/select.mjs +++ b/source/components/form/select.mjs @@ -14,7 +14,7 @@ import {instanceSymbol} from "../../constants.mjs"; import {internalSymbol} from "../../constants.mjs"; -import {buildMap} from "../../data/buildmap.mjs"; +import {buildMap,build as buildValue} from "../../data/buildmap.mjs"; import {DeadMansSwitch} from "../../util/deadmansswitch.mjs"; import {positionPopper} from "./util/floating-ui.mjs"; import { @@ -697,11 +697,12 @@ class Select extends CustomControl { * @fires monster-options-set this event is fired when the options are set */ importOptions(data) { + const self = this; const mappingOptions = this.getOption("mapping", {}); const selector = mappingOptions?.["selector"]; const labelTemplate = mappingOptions?.["labelTemplate"]; const valueTemplate = mappingOptions?.["valueTemplate"]; - const filter = mappingOptions?.["filter"]; + let filter = mappingOptions?.["filter"]; let flag = false; if (labelTemplate === "") { @@ -717,6 +718,36 @@ class Select extends CustomControl { if (flag === true) { throw new Error("missing label configuration"); } + if (isString(filter) ) { + if (0 === filter.indexOf('run:')) { + const code = filter.replace('run:', ''); + filter = (m, v, k) => { + const fkt = new Function('m', 'v', 'k', "control", code); + return fkt(m, v, k, self); + } + } else if (0 === filter.indexOf('call:')) { + + const parts = filter.split(':'); + parts.shift(); // remove prefix + const fkt = parts.shift(); + + switch (fkt) { + case "filterValueOfAttribute": + + const attribute = parts.shift(); + const attrValue = self.getAttribute(attribute); + + filter = (m, v, k) => { + const mm = buildValue(m, valueTemplate); + return mm != attrValue; // no type check, no !== + } + break; + + default: + addErrorAttribute(this, new Error(`Unknown filter function ${fkt}`)); + } + } + } const map = buildMap(data, selector, labelTemplate, valueTemplate, filter); diff --git a/source/components/form/tree-select.mjs b/source/components/form/tree-select.mjs index 75288f9169134eff0cb879db7e9b9663239ad939..837c41d9f6305d725a39171386020c6cd5e3ac40 100644 --- a/source/components/form/tree-select.mjs +++ b/source/components/form/tree-select.mjs @@ -12,37 +12,38 @@ * SPDX-License-Identifier: AGPL-3.0 */ -import { buildTree } from "../../data/buildtree.mjs"; +import {buildTree} from "../../data/buildtree.mjs"; import { - addAttributeToken, - findClosestByAttribute, + addAttributeToken, + findClosestByAttribute, } from "../../dom/attributes.mjs"; import { - ATTRIBUTE_ERRORMESSAGE, - ATTRIBUTE_ROLE, - ATTRIBUTE_UPDATER_INSERT_REFERENCE, + ATTRIBUTE_ERRORMESSAGE, + ATTRIBUTE_ROLE, + ATTRIBUTE_UPDATER_INSERT_REFERENCE, } from "../../dom/constants.mjs"; -import { instanceSymbol } from "../../constants.mjs"; +import {instanceSymbol} from "../../constants.mjs"; import { - assembleMethodSymbol, - registerCustomElement, + assembleMethodSymbol, + registerCustomElement, } from "../../dom/customelement.mjs"; import { - findTargetElementFromEvent, - fireCustomEvent, - fireEvent, + findTargetElementFromEvent, + fireCustomEvent, + fireEvent, } from "../../dom/events.mjs"; -import { Formatter } from "../../text/formatter.mjs"; -import { isString } from "../../types/is.mjs"; -import { Node } from "../../types/node.mjs"; -import { NodeRecursiveIterator } from "../../types/noderecursiveiterator.mjs"; -import { validateInstance } from "../../types/validate.mjs"; -import { ATTRIBUTE_FORM_URL, ATTRIBUTE_INTEND } from "./constants.mjs"; -import { Select } from "./select.mjs"; -import { SelectStyleSheet } from "./stylesheet/select.mjs"; -import { TreeSelectStyleSheet } from "./stylesheet/tree-select.mjs"; - -export { TreeSelect, formatHierarchicalSelection }; +import {Formatter} from "../../text/formatter.mjs"; +import {isString} from "../../types/is.mjs"; +import {Node} from "../../types/node.mjs"; +import {NodeRecursiveIterator} from "../../types/noderecursiveiterator.mjs"; +import {validateInstance} from "../../types/validate.mjs"; +import {ATTRIBUTE_FORM_URL, ATTRIBUTE_INTEND} from "./constants.mjs"; +import {Select} from "./select.mjs"; +import {SelectStyleSheet} from "./stylesheet/select.mjs"; +import {TreeSelectStyleSheet} from "./stylesheet/tree-select.mjs"; +import {addErrorAttribute} from "../../dom/error.mjs"; + +export {TreeSelect, formatHierarchicalSelection}; /** * @private @@ -72,161 +73,201 @@ const keyEventHandler = Symbol("keyEventHandler"); * @fires monster-changed */ class TreeSelect extends Select { - /** - * This method is called by the `instanceof` operator. - * @return {symbol} - * @since 2.1.0 - */ - static get [instanceSymbol]() { - return Symbol.for("@schukai/monster/components/form/tree-select@@instance"); - } - - /** - * 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. - * - * @extends Select - * @property {Array} mapping.rootReferences=['0', undefined, null] - * @property {String} mapping.idTemplate=id - * @property {String} mapping.parentTemplate=parent - * @property {String} mapping.selection - * @property {Object} formatter - * @property {String} formatter.separator=" / " - */ - get defaults() { - return Object.assign( - {}, - super.defaults, - { - mapping: { - rootReferences: ["0", undefined, null], - id: "id", - parent: "parent", - - selector: "*", - labelTemplate: "", - valueTemplate: "", - }, - formatter: { - selection: formatHierarchicalSelection, - separator: " / ", - }, - templates: { - main: getTemplate(), - }, - }, - initOptionsFromArguments.call(this), - ); - } - - /** - * - * @return {string} - */ - static getTag() { - return "monster-tree-select"; - } - - /** - * - * @return {CSSStyleSheet[]} - */ - static getCSSStyleSheet() { - return [SelectStyleSheet, TreeSelectStyleSheet]; - } - - /** - * Import Select Options from dataset - * Not to be confused with the control defaults/options - * - * @param {array|object|Map|Set} data - * @return {Select} - * @throws {Error} map is not iterable - */ - importOptions(data) { - this[internalNodesSymbol] = new Map(); - - const mappingOptions = this.getOption("mapping", {}); - - const filter = mappingOptions?.["filter"]; - const rootReferences = mappingOptions?.["rootReferences"]; - - const id = this.getOption("mapping.id", "id"); - const parentID = this.getOption("mapping.parent", "parent"); - - const selector = mappingOptions?.["selector"]; - const options = []; - - try { - const nodes = buildTree(data, selector, id, parentID, { - filter, - rootReferences, - }); - - for (const node of nodes) { - const iterator = new NodeRecursiveIterator(node); - for (const n of iterator) { - const formattedValues = formatKeyLabel.call(this, n); - - const label = formattedValues.label; - const value = formattedValues.value; - const intend = n.level; - - const visibility = intend > 0 ? "hidden" : "visible"; - const state = "close"; - - this[internalNodesSymbol].set(value, n); - - options.push({ - value, - label, - intend, - state, - visibility, - ["has-children"]: n.hasChildNodes(), - }); - } - } - - this.setOption("options", options); - - fireCustomEvent(this, "monster-options-set", { - options, - }); - } catch (e) { - addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e?.message || e); - } - - return this; - } - - /** - * - * @return {TreeSelect} - */ - [assembleMethodSymbol]() { - super[assembleMethodSymbol](); - initEventHandler.call(this); - } + /** + * This method is called by the `instanceof` operator. + * @return {symbol} + * @since 2.1.0 + */ + static get [instanceSymbol]() { + return Symbol.for("@schukai/monster/components/form/tree-select@@instance"); + } + + /** + * 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. + * + * @extends Select + * @property {Array} mapping.rootReferences=['0', undefined, null] + * @property {String} mapping.idTemplate=id + * @property {String} mapping.parentTemplate=parent + * @property {String} mapping.selection + * @property {String} mapping.labelTemplate + * @property {String} mapping.valueTemplate + * @property {String} mapping.filter The filter function to apply to each node, you can use run: syntax to execute a function, or use call:filterValueOfAttribute:data-my-attribute. + * @property {Object} formatter + * @property {String} formatter.separator=" / " + */ + get defaults() { + return Object.assign( + {}, + super.defaults, + { + mapping: { + rootReferences: ["0", undefined, null], + id: "id", + parent: "parent", + + selector: "*", + labelTemplate: "", + valueTemplate: "", + + filter: null, + }, + formatter: { + selection: formatHierarchicalSelection, + separator: " / ", + }, + templates: { + main: getTemplate(), + }, + }, + initOptionsFromArguments.call(this), + ); + } + + /** + * + * @return {string} + */ + static getTag() { + return "monster-tree-select"; + } + + /** + * + * @return {CSSStyleSheet[]} + */ + static getCSSStyleSheet() { + return [SelectStyleSheet, TreeSelectStyleSheet]; + } + + /** + * Import Select Options from dataset + * Not to be confused with the control defaults/options + * + * @param {array|object|Map|Set} data + * @return {Select} + * @throws {Error} map is not iterable + */ + importOptions(data) { + const self = this; + + this[internalNodesSymbol] = new Map(); + + const id = this.getOption("mapping.id", "id"); + const parentID = this.getOption("mapping.parent", "parent"); + + const mappingOptions = this.getOption("mapping", {}); + + let filter = mappingOptions?.["filter"]; + + if (isString(filter) ) { + if (0 === filter.indexOf('run:')) { + const code = filter.replace('run:', ''); + filter = (m, v, k) => { + const fkt = new Function('m', 'v', 'k', "control", code); + return fkt(m, v, k, self); + } + } else if (0 === filter.indexOf('call:')) { + + const parts = filter.split(':'); + parts.shift(); // remove prefix + const fkt = parts.shift(); + + switch (fkt) { + case "filterValueOfAttribute": + + const attribute = parts.shift(); + const attrValue = self.getAttribute(attribute); + + filter = (m, v, k) => { + return m?.[id] != attrValue; // no type check, no !== + } + break; + + default: + addErrorAttribute(this, new Error(`Unknown filter function ${fkt}`)); + } + } + + } + + const rootReferences = mappingOptions?.["rootReferences"]; + + const selector = mappingOptions?.["selector"]; + const options = []; + + try { + const nodes = buildTree(data, selector, id, parentID, { + filter, + rootReferences, + }); + + for (const node of nodes) { + const iterator = new NodeRecursiveIterator(node); + for (const n of iterator) { + const formattedValues = formatKeyLabel.call(this, n); + + const label = formattedValues.label; + const value = formattedValues.value; + const intend = n.level; + + const visibility = intend > 0 ? "hidden" : "visible"; + const state = "close"; + + this[internalNodesSymbol].set(value, n); + + options.push({ + value, + label, + intend, + state, + visibility, + ["has-children"]: n.hasChildNodes(), + }); + } + } + + this.setOption("options", options); + + fireCustomEvent(this, "monster-options-set", { + options, + }); + } catch (e) { + addErrorAttribute(this, e); + } + + return this; + } + + /** + * + * @return {TreeSelect} + */ + [assembleMethodSymbol]() { + super[assembleMethodSymbol](); + initEventHandler.call(this); + } } + /** * @private * @param event */ function handleOptionKeyboardEvents(event) { - switch (event?.["code"]) { - case "ArrowLeft": - closeOrOpenCurrentOption.call(this, event, "close"); - event.preventDefault(); - break; - case "ArrowRight": - closeOrOpenCurrentOption.call(this, event, "open"); - event.preventDefault(); - break; - } + switch (event?.["code"]) { + case "ArrowLeft": + closeOrOpenCurrentOption.call(this, event, "close"); + event.preventDefault(); + break; + case "ArrowRight": + closeOrOpenCurrentOption.call(this, event, "open"); + event.preventDefault(); + break; + } } /** @@ -234,24 +275,24 @@ function handleOptionKeyboardEvents(event) { * @param {event} event */ function closeOrOpenCurrentOption(event, mode) { - validateInstance(event, Event); - - if (typeof event.composedPath !== "function") { - throw new Error("unsupported event"); - } - - const path = event.composedPath(); - const optionNode = path.shift(); - - const state = optionNode.getAttribute("data-monster-state"); - if (state !== mode) { - const handler = optionNode.querySelector( - "[data-monster-role=folder-handler]", - ); - if (handler instanceof HTMLElement) { - fireEvent(handler, "click"); - } - } + validateInstance(event, Event); + + if (typeof event.composedPath !== "function") { + throw new Error("unsupported event"); + } + + const path = event.composedPath(); + const optionNode = path.shift(); + + const state = optionNode.getAttribute("data-monster-state"); + if (state !== mode) { + const handler = optionNode.querySelector( + "[data-monster-role=folder-handler]", + ); + if (handler instanceof HTMLElement) { + fireEvent(handler, "click"); + } + } } /** @@ -261,25 +302,25 @@ function closeOrOpenCurrentOption(event, mode) { * @private */ function formatKeyLabel(node) { - validateInstance(node, Node); + validateInstance(node, Node); - const v = node.value; - if (v === undefined) { - throw new Error("the object has no value for the specified id"); - } + const v = node.value; + if (v === undefined) { + throw new Error("the object has no value for the specified id"); + } - const label = new Formatter(v).format( - this.getOption("mapping.labelTemplate", ""), - ); + const label = new Formatter(v).format( + this.getOption("mapping.labelTemplate", ""), + ); - const value = new Formatter(v).format( - this.getOption("mapping.valueTemplate", ""), - ); + const value = new Formatter(v).format( + this.getOption("mapping.valueTemplate", ""), + ); - return { - value, - label, - }; + return { + value, + label, + }; } /** @@ -288,27 +329,27 @@ function formatKeyLabel(node) { * @return {Array} */ function buildTreeLabels(value) { - if (!this[internalNodesSymbol]) { - return [value]; - } - - let node = this[internalNodesSymbol].get(value); - if (node === undefined) { - node = this[internalNodesSymbol].get(parseInt(value)); - } - - const parts = []; - - if (node instanceof Node) { - let ptr = node; - while (ptr) { - const formattedValues = formatKeyLabel.call(this, ptr); - parts.unshift(formattedValues.label); - ptr = ptr.parent; - } - } - - return parts; + if (!this[internalNodesSymbol]) { + return [value]; + } + + let node = this[internalNodesSymbol].get(value); + if (node === undefined) { + node = this[internalNodesSymbol].get(parseInt(value)); + } + + const parts = []; + + if (node instanceof Node) { + let ptr = node; + while (ptr) { + const formattedValues = formatKeyLabel.call(this, ptr); + parts.unshift(formattedValues.label); + ptr = ptr.parent; + } + } + + return parts; } /** @@ -326,9 +367,9 @@ function buildTreeLabels(value) { * @return {string} */ function formatHierarchicalSelection(value) { - return buildTreeLabels - .call(this, value) - .join(this.getOption("formatter.separator", " / ")); + return buildTreeLabels + .call(this, value) + .join(this.getOption("formatter.separator", " / ")); } /** @@ -342,99 +383,99 @@ const openOptionEventHandler = Symbol("openOptionEventHandler"); * @throws {Error} no shadow-root is defined */ function initEventHandler() { - if (!this.shadowRoot) { - throw new Error("no shadow-root is defined"); - } - - this[openOptionEventHandler] = (event) => { - const element = findTargetElementFromEvent( - event, - ATTRIBUTE_ROLE, - "folder-handler", - ); - if (!(element instanceof HTMLElement)) { - return; - } - - const container = findClosestByAttribute(element, ATTRIBUTE_ROLE, "option"); - const index = container - .getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE) - .split("-") - .pop(); - - const currentState = this.getOption(`options.${index}.state`); - - const newState = currentState === "close" ? "open" : "close"; - this.setOption(`options.${index}.state`, newState); - - const newVisibility = newState === "open" ? "visible" : "hidden"; - - if (container.hasAttribute(ATTRIBUTE_INTEND)) { - const intend = container.getAttribute(ATTRIBUTE_INTEND); - - let ref = container.nextElementSibling; - const childIntend = parseInt(intend) + 1; - - const cmp = (a, b) => { - if (newState === "open") { - return a === b; - } - - return a >= b; - }; - - while ( - ref?.hasAttribute(ATTRIBUTE_INTEND) && - cmp(parseInt(ref.getAttribute(ATTRIBUTE_INTEND)), childIntend) - ) { - const refIndex = ref - .getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE) - .split("-") - .pop(); - this.setOption(`options.${refIndex}.visibility`, newVisibility); - - if (newState === "close") { - this.setOption(`options.${refIndex}.state`, "close"); - } - - ref = ref.nextElementSibling; - } - } - }; - - this[keyEventHandler] = (event) => { - const path = event.composedPath(); - const element = path?.[0]; - - let role; - - if (element instanceof HTMLElement) { - if (element.hasAttribute(ATTRIBUTE_ROLE)) { - role = element.getAttribute(ATTRIBUTE_ROLE); - } else if (element === this) { - show.call(this); - focusFilter.call(this); - } else { - const e = element.closest(`[${ATTRIBUTE_ROLE}]`); - if (e instanceof HTMLElement && e.hasAttribute()) { - role = e.getAttribute(ATTRIBUTE_ROLE); - } - } - } else { - return; - } - - switch (role) { - case "option-label": - case "option-control": - case "option": - handleOptionKeyboardEvents.call(this, event); - break; - } - }; - - this.shadowRoot.addEventListener("keydown", this[keyEventHandler]); - this.shadowRoot.addEventListener("click", this[openOptionEventHandler]); + if (!this.shadowRoot) { + throw new Error("no shadow-root is defined"); + } + + this[openOptionEventHandler] = (event) => { + const element = findTargetElementFromEvent( + event, + ATTRIBUTE_ROLE, + "folder-handler", + ); + if (!(element instanceof HTMLElement)) { + return; + } + + const container = findClosestByAttribute(element, ATTRIBUTE_ROLE, "option"); + const index = container + .getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE) + .split("-") + .pop(); + + const currentState = this.getOption(`options.${index}.state`); + + const newState = currentState === "close" ? "open" : "close"; + this.setOption(`options.${index}.state`, newState); + + const newVisibility = newState === "open" ? "visible" : "hidden"; + + if (container.hasAttribute(ATTRIBUTE_INTEND)) { + const intend = container.getAttribute(ATTRIBUTE_INTEND); + + let ref = container.nextElementSibling; + const childIntend = parseInt(intend) + 1; + + const cmp = (a, b) => { + if (newState === "open") { + return a === b; + } + + return a >= b; + }; + + while ( + ref?.hasAttribute(ATTRIBUTE_INTEND) && + cmp(parseInt(ref.getAttribute(ATTRIBUTE_INTEND)), childIntend) + ) { + const refIndex = ref + .getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE) + .split("-") + .pop(); + this.setOption(`options.${refIndex}.visibility`, newVisibility); + + if (newState === "close") { + this.setOption(`options.${refIndex}.state`, "close"); + } + + ref = ref.nextElementSibling; + } + } + }; + + this[keyEventHandler] = (event) => { + const path = event.composedPath(); + const element = path?.[0]; + + let role; + + if (element instanceof HTMLElement) { + if (element.hasAttribute(ATTRIBUTE_ROLE)) { + role = element.getAttribute(ATTRIBUTE_ROLE); + } else if (element === this) { + show.call(this); + focusFilter.call(this); + } else { + const e = element.closest(`[${ATTRIBUTE_ROLE}]`); + if (e instanceof HTMLElement && e.hasAttribute()) { + role = e.getAttribute(ATTRIBUTE_ROLE); + } + } + } else { + return; + } + + switch (role) { + case "option-label": + case "option-control": + case "option": + handleOptionKeyboardEvents.call(this, event); + break; + } + }; + + this.shadowRoot.addEventListener("keydown", this[keyEventHandler]); + this.shadowRoot.addEventListener("click", this[openOptionEventHandler]); } /** @@ -448,14 +489,14 @@ function initEventHandler() { * @return {object} */ function initOptionsFromArguments() { - const options = {}; + const options = {}; - const url = this.getAttribute(ATTRIBUTE_FORM_URL); - if (isString(url)) { - options["url"] = new URL(url).toString(); - } + const url = this.getAttribute(ATTRIBUTE_FORM_URL); + if (isString(url)) { + options["url"] = new URL(url).toString(); + } - return options; + return options; } /** @@ -463,8 +504,8 @@ function initOptionsFromArguments() { * @return {string} */ function getTemplate() { - // language=HTML - return ` + // language=HTML + return ` <template id="options"> <div data-monster-role="option" tabindex="-1" diff --git a/source/data/buildmap.mjs b/source/data/buildmap.mjs index 656abaf924401b348f7ddc3202043856319c1d27..3e20319e836c054327854035da70cc1c1145059e 100644 --- a/source/data/buildmap.mjs +++ b/source/data/buildmap.mjs @@ -17,7 +17,7 @@ import { validateString } from "../types/validate.mjs"; import { clone } from "../util/clone.mjs"; import { DELIMITER, Pathfinder, WILDCARD } from "./pathfinder.mjs"; -export { buildMap, PARENT, assembleParts }; +export { buildMap, PARENT, assembleParts}; /** * @type {string} @@ -361,7 +361,7 @@ function buildFlatMap(subject, selector, key, parentMap) { * @param {*} defaultValue * @return {*} */ -function build(subject, definition, defaultValue) { +export function build(subject, definition, defaultValue) { if (definition === undefined) return defaultValue ? defaultValue : subject; validateString(definition); diff --git a/source/dom/updater.mjs b/source/dom/updater.mjs index 2c1ec37e60cb09d781a34e205598655aa255b9e6..303874215f536065782bd34b9af891bd5242051f 100644 --- a/source/dom/updater.mjs +++ b/source/dom/updater.mjs @@ -395,7 +395,7 @@ function retrieveAndSetValue(element) { element.constructor.prototype, "value", )?.["get"]) || - element.hasOwnProperty("value") + "value" in element ) { value = element?.["value"]; } else {