UNPKG

stem-core

Version:

Frontend and core-library framework

342 lines (308 loc) 10.2 kB
import {dashCase, setObjectPrototype} from "../base/Utils"; export const defaultToPixelsAttributes = new Set([ "border-radius", "border-bottom-left-radius", "border-bottom-right-radius", "border-top-left-radius", "border-top-right-radius", "border-bottom-width", "border-left-width", "border-right-width", "border-top-width", "border-width", "bottom", "font-size", "font-stretch", "height", "layer-grid-char", "layer-grid-char-spacing", "layer-grid-line", "left", "letter-spacing", "line-height", "margin", "margin-bottom", "margin-left", "margin-right", "margin-top", "marker-offset", "max-height", "max-width", "min-height", "min-width", "outline-width", "padding", "padding-bottom", "padding-left", "padding-right", "padding-top", "right", "size", "top", "width", "word-spacing", ]); // Used to map from option key to a DOM attribute name. // Can recursively fall back to a base mapping, to allow extending of a parent class export class DOMAttributesMap { allowedAttributesMap = new Map(); reverseNameMap = new Map(); constructor(fallbackMapping, allowedAttributesArray = []) { this.fallbackMapping = fallbackMapping; for (let attribute of allowedAttributesArray) { if (!Array.isArray(attribute)) { attribute = [attribute]; } this.setAttribute(attribute[0], attribute[1]); } } setAttribute(key, value) { value = value || {}; value.domName = value.domName || key; this.allowedAttributesMap.set(key, value); this.reverseNameMap.set(value.domName, key); } get(key) { let value = this.allowedAttributesMap.get(key); if (!value && this.fallbackMapping) { value = this.fallbackMapping.get(key); } return value; } has(key) { return this.allowedAttributesMap.has(key) || (this.fallbackMapping && this.fallbackMapping.has(key)); } getKeyFromDOMName(key) { let value = this.reverseNameMap.get(key); if (!value && this.fallbackMapping) { value = this.fallbackMapping.getKeyFromDOMName(key); } return value; } } // A class that can be used to work with a className field as with a Set, while having a toString() usable in the DOM // It's used when a UI object has a className attribute, that a string, but we still want it to be modified if we call addClass and removeClass // In that case, the string gets converted to a ClassNameSet export class ClassNameSet extends Set { // Can't use classic super in constructor since Set is build-in type and will throw an error // TODO: see if could still be made to have this as constructor static create(className) { let value = new Set(String(className || "").split(" ")); return setObjectPrototype(value, this); } toString() { return Array.from(this).join(" "); } } export class NodeAttributes { constructor(obj) { Object.assign(this, obj); // className and style should be deep copied to be modifiable, the others shallow copied if (this.className instanceof ClassNameSet) { this.className = ClassNameSet.create(String(this.className)); } if (this.style) { this.style = Object.assign({}, this.style); } } // TODO: should this use the domName or the reverseName? Still needs work setAttribute(key, value, node, attributesMap=this.constructor.defaultAttributesMap) { // TODO: might want to find a better way than whitelistAttributes field to do this if (!attributesMap.has(key)) { this.whitelistedAttributes = this.whitelistedAttributes || {}; this.whitelistedAttributes[key] = true; } this[key] = value; if (node) { this.applyAttribute(key, node, attributesMap); } } applyStyleToNode(key, value, node) { if (typeof value === "function") { value = value(); } if ((value instanceof Number || typeof value === "number") && value != 0 && defaultToPixelsAttributes.has(dashCase(key))) { value = value + "px"; } if (node && node.style[key] !== value) { node.style[key] = value; } } setStyle(key, value, node) { if (!(typeof key === "string" || key instanceof String)) { // If the key is not a string, it should be a plain object for (const styleKey of Object.keys(key)) { this.setStyle(styleKey, key[styleKey], node); } return; } if (value === undefined) { this.removeStyle(key, node); return; } this.style = this.style || {}; this.style[key] = value; this.applyStyleToNode(key, value, node); } removeStyle(key, node) { if (this.style) { delete this.style[key]; } if (node && node.style[key]) { delete node.style[key]; } } static getClassArray(classes) { if (!classes) { return []; } if (Array.isArray(classes)) { return classes.map(x => String(x).trim()); } else { return String(classes).trim().split(" "); } } getClassNameSet() { if (!(this.className instanceof ClassNameSet)) { this.className = ClassNameSet.create(this.className || ""); } return this.className; } addClass(classes, node) { classes = this.constructor.getClassArray(classes); for (let cls of classes) { this.getClassNameSet().add(cls); if (node) { node.classList.add(cls); } } } removeClass(classes, node) { classes = this.constructor.getClassArray(classes); for (let cls of classes) { this.getClassNameSet().delete(cls); if (node) { node.classList.remove(cls); } } } hasClass(className) { return this.getClassNameSet().has(typeof className === "string" ? className : className.className); } applyAttribute(key, node, attributesMap) { let attributeOptions = attributesMap.get(key); if (!attributeOptions) { if (this.whitelistedAttributes && (key in this.whitelistedAttributes)) { attributeOptions = { domName: key, } } else { return; } } let value = this[key]; if (typeof value === "function") { value = value(); } if (attributeOptions.noValue) { if (value) { value = ""; } else { value = undefined; } } if (typeof value !== "undefined") { node.setAttribute(attributeOptions.domName, value); } else { node.removeAttribute(attributeOptions.domName); } } applyClassName(node) { if (this.className) { const className = String(this.className); if (node.className != className) { node.className = className; } } else { if (node.className) { node.removeAttribute("class"); } } } apply(node, attributesMap) { let addedAttributes = {}; let whitelistedAttributes = this.whitelistedAttributes || {}; // First update existing node attributes and delete old ones // TODO: optimize to not run this if the node was freshly created let nodeAttributes = node.attributes; for (let i = nodeAttributes.length - 1; i >= 0; i--) { let attr = nodeAttributes[i]; let attributeName = attr.name; if (attributeName === "style" || attributeName === "class") { // TODO: maybe should do work here? continue; } let key = attributesMap.getKeyFromDOMName(attributeName); if (this.hasOwnProperty(key)) { let value = this[key]; let attributeOptions = attributesMap.get(key); if (attributeOptions && attributeOptions.noValue) { if (value) { value = ""; } else { value = undefined; } } if (value != null) { node.setAttribute(attributeName, value); addedAttributes[key] = true; } else { node.removeAttribute(attributeName); } } else { node.removeAttribute(attributeName); } } // Add new attributes for (let key in this) { if (addedAttributes[key]) { continue; } this.applyAttribute(key, node, attributesMap); // TODO: also whitelist data- and aria- keys here } this.applyClassName(node); node.removeAttribute("style"); if (this.style) { for (let key in this.style) { this.applyStyleToNode(key, this.style[key], node); } } } } // Default node attributes, should be as few of these as possible NodeAttributes.defaultAttributesMap = new DOMAttributesMap(null, [ ["id"], ["action"], ["colspan"], ["default"], ["disabled", {noValue: true}], ["fixed"], ["forAttr", {domName: "for"}], // TODO: have a consistent nomenclature for there! ["hidden"], ["href"], ["rel"], ["minHeight"], ["minWidth"], ["role"], ["target"], ["HTMLtitle", {domName: "title"}], ["type"], ["placeholder"], ["src"], ["height"], ["width"], ["tabIndex"], //["value"], // Value is intentionally disabled ]);