UNPKG

bg-atom-redom-ui

Version:

Atom plugin UI Component library using REDOM and compliant with the Atom style guide

670 lines (624 loc) 34.3 kB
import { el, mount as redomMount, unmount as redomUnmount, text } from 'redom'; // redom does not export this function so repeat it export function getEl (parent) { return (parent.nodeType && parent) || (!parent.el && parent) || getEl(parent.el); } // compiled common Regular Expressions export const reEmpty = /^\s*$/; export const reHTMLContent = /^\s*<[^>]+>/; export const reVarName = /^[_a-zA-Z][_a-zA-Z0-9]*$/; // reTagIDClasses makes tagName the default text // [name:][<tagName>][#<idName>][.className1[.className2...]][ textContent] export const reTagIDClasses = /^((?<name>[_a-zA-Z0-9]*):)?(?<tagName>[-_a-zA-Z0-9]*)?(#(?<idName>[-_a-zA-Z0-9]*))?(?<className>[.][-!_.a-zA-Z0-9]*)?([\s,]((?<icon>icon-[-_a-zA-Z0-9]+)([\s,]|$))?(?<label>.*))?$/; // reContentIDClasses makes content the default text and changes tagName to require a leading $ // the re group names are the parameter names. This re must match '' (all groups are optional) // [name:][$<tagName>][#<idName>][.className1[.className2...]][ textContent] export const reContentIDClasses = /^((?<name>[_a-zA-Z0-9]*):)?([$](?<tagName>[-_a-zA-Z0-9]*))?(#(?<idName>[-_a-zA-Z0-9]*))?(?<className>[.][-!_.a-zA-Z0-9]*)?(\s+|,|$)?((?<icon>icon-[-_a-zA-Z0-9]+)(\s+|,|$))?(?<label>.*)?$/; export const bgComponent=Symbol.for('bgComponent'); // This map looks up a name and returns true if it is a known style property name. It is used to move options object's member names // that the user puts at the top level into the 'style' sub-member. This makes it easier for users to specify just a few styles. // without having the verbosity of creating the style sub-object. // This list was created from https://www.w3schools.com/jsref/dom_obj_style.asp const knownStyleProperties = { alignContent: true, alignItems: true, alignSelf: true, animation: true, animationDelay: true, animationDirection: true, animationDuration: true, animationFillMode: true, animationIterationCount: true, animationName: true, animationTimingFunction: true, animationPlayState: true, background: true, backgroundAttachment: true, backgroundColor: true, backgroundImage: true, backgroundPosition: true, backgroundRepeat: true, backgroundClip: true, backgroundOrigin: true, backgroundSize: true, backfaceVisibility: true, border: true, borderBottom: true, borderBottomColor: true, borderBottomLeftRadius: true, borderBottomRightRadius: true, borderBottomStyle: true, borderBottomWidth: true, borderCollapse: true, borderColor: true, borderImage: true, borderImageOutset: true, borderImageRepeat: true, borderImageSlice: true, borderImageSource: true, borderImageWidth: true, borderLeft: true, borderLeftColor: true, borderLeftStyle: true, borderLeftWidth: true, borderRadius: true, borderRight: true, borderRightColor: true, borderRightStyle: true, borderRightWidth: true, borderSpacing: true, borderStyle: true, borderTop: true, borderTopColor: true, borderTopLeftRadius: true, borderTopRightRadius: true, borderTopStyle: true, borderTopWidth: true, borderWidth: true, bottom: true, boxDecorationBreak: true, boxShadow: true, boxSizing: true, captionSide: true, clear: true, clip: true, color: true, columnCount: true, columnFill: true, columnGap: true, columnRule: true, columnRuleColor: true, columnRuleStyle: true, columnRuleWidth: true, columns: true, columnSpan: true, columnWidth: true, content: true, counterIncrement: true, counterReset: true, cursor: true, direction: true, display: true, emptyCells: true, filter: true, flex: true, flexBasis: true, flexDirection: true, flexFlow: true, flexGrow: true, flexShrink: true, flexWrap: true, cssFloat: true, font: true, fontFamily: true, fontSize: true, fontStyle: true, fontVariant: true, fontWeight: true, fontSizeAdjust: true, fontStretch: true, hangingPunctuation: true, height: true, hyphens: true, icon: true, imageOrientation: true, isolation: true, justifyContent: true, left: true, letterSpacing: true, lineHeight: true, listStyle: true, listStyleImage: true, listStylePosition: true, listStyleType: true, margin: true, marginBottom: true, marginLeft: true, marginRight: true, marginTop: true, maxHeight: true, maxWidth: true, minHeight: true, minWidth: true, navDown: true, navIndex: true, navLeft: true, navRight: true, navUp: true, objectFit: true, objectPosition: true, opacity: true, order: true, orphans: true, outline: true, outlineColor: true, outlineOffset: true, outlineStyle: true, outlineWidth: true, overflow: true, overflowX: true, overflowY: true, padding: true, paddingBottom: true, paddingLeft: true, paddingRight: true, paddingTop: true, pageBreakAfter: true, pageBreakBefore: true, pageBreakInside: true, perspective: true, perspectiveOrigin: true, position: true, quotes: true, resize: true, right: true, tableLayout: true, tabSize: true, textAlign: true, textAlignLast: true, textDecoration: true, textDecorationColor: true, textDecorationLine: true, textDecorationStyle: true, textIndent: true, textJustify: true, textOverflow: true, textShadow: true, textTransform: true, top: true, transform: true, transformOrigin: true, transformStyle: true, transition: true, transitionProperty: true, transitionDuration: true, transitionTimingFunction:true, transitionDelay: true, unicodeBidi: true, userSelect: true, verticalAlign: true, visibility: true, whiteSpace: true, width: true, wordBreak: true, wordSpacing: true, wordWrap: true, widows: true, zIndex: true } // Interpret and normalize information about a component being created that has come from multiple levels in the type hierarchy. // This supports a very flexible yet reasonably efficient pattern of providing information from multiple places using a compact // easy to use syntax. // // Levels: // user contruction params :(new SomeComponent(...)) // componentClass1 :(added in class's ctor -- class being constructed) // componentClass2 :(added in class's ctor -- first super class) // componentClass... :(added in class's ctor -- all the super classes in the hierarchy chain) // Component class :(added in Component's ctor -- most super class invokes us to collect all the information) // // The concept of 'levels' will be used to describe the functionality of ComponentParams. Each level is a place that can provide // information about how the component should be constructed. This is true in all class hierarchies but the special nature of // programming to the DOM makes it particularly useful to be flexible in allowing each level to provide (almost) any information. // // Each level can add its own information to the parameter list that it received from its caller and pass the combined result // onto its base class. The last base class (which may or may not be Component) uses ComponentParams to reduce the arbitrarily // long parameter list into one normalized set of information that is required to create a new DOM Node. This refined information // is then available in each of the component class's constructors in the hierarchy. // // Information earlier in the parameter list (more to the left) will supercede information later in the list (more to the right). // with the same name. A component class can use this to decide whether the information it provides will be a default value that // the level above it can override or whether it is a mandatory value that can not be overridden. // // Passing Information as Named vs Unnamed (positional) : // No parameter passed to the ComponentParams constructor is identified by its position so there are really no positional parameters. // However, any of the supported unamed parameters can be specified in any order and can be repeated any number of times so there // are an arbitrarily large number of what is typically called 'positional parameters'. Named parameters are implemented by passing // an object whose keys are the names of the parameters. Some of the objects in the positional parameter list will be identified // and used this way to pass explicitly named parameters. Amoung these named parameter objects, several other types of // 'positional parameters' are supported and identified by their types instead of their position. // // Repeated params of the same logical type ocur when derived component classes pass through params from their constructor onto // their base component super class constructor. Each level can add its own parameters of any of the supported logical types // and sometimes will add two -- one on the left to contain mandatory value(s) and another on the right to conatin default value(s). // // This support avoids each derived class in the component hierarchy from having to handle complicated merging of parameters // themselves. Not only would that make it harder to write and maintain components, but also each component might do it a little // differently making the hierarchy as a whole less consistent and harder to use. // // The reason other parameter types besides named parameter objects are supported is to provide a more user friendly, compact // syntax for creating components. // // Named (Logical) Parameters : // These are the named 'parameters' that can be explicitly specified in a named parameter object that apprears in the // 'positional parameter' list. Note that the names that are not in <> are the literal keys that will be recognized. Those that are // in <> like <knownStyles> refer to a whole set of names that will be recognized -- in this case any of the CSS style attributes. // tagName :string : <tagName> The name of the dom/html element type // name :string : the variable name used for a component. Typically this is relative to its parent. // idName :string : the id attribute. Not typically used b/c it should only be used when it does not make sense for a page to contain more than one of the component class its used in. // classes :stringSet : space separated list of classNames // label :string : text that will be set as the direct content of this node. If is starts with an html tag (<div>) it will parsed as html // content :<multiple> : innerHTML specified in multiple ways -- text, html, DOM node, Component instance, or an array of multiple of those // tagIDClasses :string(aggregate): combined [<name>:][<tagName>][#<idName>][.<class>[.<class>...]][ <contentTestOrHTML>] // optParams :object : named parameters introduced by a component class for features it implements (as opposed to passed through to the DOM) // paramNames :stringSet : space separated list of optional parameter names that can be specified in the options object // props :object : Attributes / Properties to be set in the created DOM node // styles :object : css styles to be set in the created DOM node // unnamedCB :function : a function that will be registered in the component's default callback event. // <paramNames> :<any> : in the options object, any name listed in 'paramNames' will be interpreted as if they were specified within 'optParams' // <knownStyles> :string : in the options object, any name listed in knownStyleProperties will be interpreted as if it were specified inside 'styles' // <unknownNames>:string : in the options object, any name that does not match any known name will be interpreted as if it were specified inside 'props' // // Flattening the Property, Styles, and optParam Namespaces: // An important way to make the syntax to build components more compact is the support to specify any (name,value) pair at the // top level of the named parameter object even if they really belong inside one of the optParam, styles, or props sub-objects. // The ComponentParams class will recognize what they are and move them into the correct sub-object so that component authors can // always access them in their proper place even if they were not specified there explicitly. // // For each name which is not one of the fixed, known names above that appears at the top level, a map lookup will be required // to identify what it is. I consider this an acceptable tradeoff, but if you do not, explicitly specifying those names in their // proper sub-objects will avoid that map lookup. // // Another issue is whether this will create name conflicts if the same name appears in more than one of the 3 subobjects. I know // of no conflict between CSS style and DOM prop names and I think that optNames should not be a problem because they are all // logically attributes of the same component/DOM Node concept. If a component author creates a parameter name with the same // name as a style or prop, they probably are refering to the same thing and need to confront why they are not reusing the builtin // style or property. // // A component author can purposely create an optParam name (see paramNames) with the same name as a style or prop to prevent // it's base classes from setting it in the DOM node or doing special processing on that value. By doing this, if the value is // specified at any level, it will be moved to the optParams sub-object where the component author can decide what to do with it. // // Unamed Params (positional): // Note that a derived component class can define its own required or optional positional constructor params for users of that // class to follow. After its own positional params, it must allow an arbitrary number of these Component class params in any order. // <tagIDClasses> : string parameters that reach this ComponentParams are always interpretted as a 'tagIDClasses'. Users can use // this to tersely specify any combination of <name>, <tagName>, <idName>, <class>, or <label> instead of explicitly // putting them inside a named parameter object. // // <content> : Arrays and also Objects with either an 'el' or 'nodeType' property are interpretted as childContent that will // be added to the new component instance. // // <options> : any object param that does not match the content conditions are interpretted as a named parameter object. The // keys of these objects can contain any of the names listed in the Named Parameter Section. // // <unnamedCB> : any function parameter will be added to this.unnamedCB[] where a derived class can access it. Its not uncommon // for a component type to support a callback that is unabiguous in the context of that component type so it can appear // anywhere in the parameter list and still be understood. Multiple levels could provide a callback in which case its // reasonable for the derived class to invoke each in turn when firing its event. // // Combining Knowledge Provided By Multiple Levels: // Each level in the hirarchy gets a chance to specify any of the information so repeated information will be inevitable. The process // of combining multiple sets of possibly overlapping information into one consistent set will be refered to in this class as 'reducing'. // i.e. we are reducing multiple sets into one set. // // Named values are either single valued or multivalued. The default behavior is to read the information from left to right on // the parameter list and lock in the first ocurrance of each single valued named data and to append together each multivalued // named data. // // A particular class in the component hierarchy can use this behavior to provide both default values that can be overriden by // things that use it or mandatory values that can not be overridden (by default). Parametes on the left override those on the right. // // All named data except these are treated as single valued. // class : multivalued : classes are combined in a space separated list. If any className is prefixed with a '!' character, it // will cause class to become locked at that point and no additional classes will be added (as if it were a single valued) // paramNames : multivalued : will be combined into a space separated list. (does not support the '!' character like class does) // content : multivalued : content is kept in an array and each content parameter encountered will be appending to the array. // in the end, the array will be reversed so that the effect is that content provided by dervied classes will by default // appear after content provided in a base class. // unnamedCB : multivalued : combined into an array // optParams,props,styles : these are containers of named data and each key is treated separately according to its name. // export class ComponentParams { // params can be any number of the following in any order // tagIDClasses:string, options:object, callback:function, domEl:object(w/nodeType), component:object(w/el), or content:array constructor(...params) { // single valued params are defined as undefined and combinable params are initialized to their empty type this.tagName = undefined; this.name = undefined; this.idName = undefined; this.className = ''; this.content = []; this.optParams = {}; this.paramNames = 'label icon '; this.props = {}; this.styles = {}; this.unnamedCB = []; this.lockedParams = {}; this.mapOfOptParamNames = {}; // first, make a quick pass to assemble all the paramNames so that we can correctly classify optional parameters declared // by all the component classes in the hierarchy. paramNames can only be set in options objects. for (var i=0; i<params.length; i++) { if (params[i] && typeof params[i] == 'object' && typeof params[i].paramNames == 'string') this.paramNames += ' '+params[i].paramNames; } // make a map of the optParams names for efficient classification lookups. for (var name of this.paramNames.replace(/\s+/g,' ').split(' ')) this.mapOfOptParamNames[name] = true; // now do a second pass through the params that does the real work. The purpose is to reduce the arbitrarily long list of // tagIDClasses, options, functions, and content into one set of information about the component instance being created. for (var i=0; i<params.length; i++) if (params[i]) { switch (typeof params[i]) { case 'object': // detect any content type object if (Array.isArray(params[i]) || ('nodeType' in params[i]) || ('el' in params[i])) // it would make sense to spread the array into the the content array except we are appending in the wrong // direction. Its more efficient to append in the wrong direction and then in the end, reverse the array. this.reduceAttribute('content', params[i]); else { // its an options object that explicitly names the information so iterate and reduce the information inside for (var name in params[i]) this.reduceAttribute(name, params[i][name]); } break; // note that we could check to see if the string matches the reContentIDClasses regex and treat it as content otherwise // but its more conservative to let reduceAttribute throw an exception since it could be a subtle error in syntax. case 'string': // its a tagIDClasses -- parse and process this.reduceAttribute('tagIDClasses', params[i]); break; case 'function': // its an unnamedCB this.reduceAttribute('unnamedCB', params[i]); break; } } // the content array may have aritrary nested arrays that could be flattened, but I think its not necessary because // Component.mount handles it. Nesting arrays do not introduse a correspnding DOM Node layer -- mount will flatten them. this.content.reverse(); // its actually class style but it seems like it should be called styles so make an alias so we can use either one. this.style = this.styles; } // return a classifier string which determines how to reduce the attribute classifyAttribute(name) { if (/^(content|paramNames|tagIDClasses|unnamedCB)$/.test(name)) return name; else if (/(className|class|classes|classNames)/.test(name)) return 'className'; else if (/(tagName|name|idName)/.test(name)) return 'top'; else if (name == 'children') return 'content'; else if (name == 'optParams') return 'optParams'; else if (name == 'styles') return 'styles'; else if (name == 'style') return 'styles'; else if (name == 'props') return 'props'; else if (name in this.mapOfOptParamNames) return 'optParams'; else if (name in knownStyleProperties) return 'styles'; else return 'props'; } // this function is used to process the contents of an options object for which the value has a name (unlike positional params) // We will classify the attribute based on its name and then reduced them differently based on their classification reduceAttribute(name, value) { // skip if there is no value. if (value == null) return; // the classifier will tell us how to reduce it var attrClassifier = this.classifyAttribute(name); var objContainer switch (attrClassifier) { case 'children': case 'content': this.content.push(value); return; case 'className': // classes are not first come first server except that '!' prevents additional classes from base classes from being added. if (Array.isArray(value)) value = value.join(' '); if (/[!]/.test(value)) this.lockedParams[name] = true; this.className += " "+value.replace(/[!.]/g, ' '); return; case 'paramNames': // we already collected the paramNames in a first pass so that we can classify in the second pass so just ignore here return; case 'tagIDClasses': var matched = reContentIDClasses.exec(value); console.assert(!!matched, "invalid tagIDClasses string syntax. ", {name:name,value:value}); // the group names in reContentIDClasses correspond to the real attribute names so matched.group can be reduced like // any options object for (var name in matched.groups) this.reduceAttribute(name, matched.groups[name]); return; case 'unnamedCB': this.unnamedCB.push(value); return; // set the objContainer on these so that we can handle them all with a common algorithm below case 'top': objContainer = this; break; case 'props': objContainer = this.props; break; case 'styles': objContainer = this.styles; break; case 'style': objContainer = this.styles; break; case 'optParams': objContainer = this.optParams; break; } // these are containers for these types of attributes so iterate and reduce each key in them if (/(optParams|styles|style|props)/.test(name)) { for (const key in value) this.reduceClassifiedAttr(objContainer, attrClassifier, key, value[key]); } else { // since props is the default when the name is not recognized, and we think that DOM properties can not be objects // (just string and numbers) send the attr to optParams if the value is an object if (attrClassifier=='props' && typeof value == 'object') this.reduceClassifiedAttr(this.optParams, 'optParams', name, value); else this.reduceClassifiedAttr(objContainer, attrClassifier, name, value); } } // this function is used when the attribute <name> is classified and its not onw of the special cases. // it sets the attributes value in the right place and records it as locked so that it wont be overwritten if a lower base class // also includes a value for it. reduceClassifiedAttr(obj, classification, name, value) { classification = (classification=='top') ? '' : classification+'.'; if (classification+name in this.lockedParams) return; obj[name] = value; this.lockedParams[classification+name] = true; } // we can probably make the dom el more effieciently than redom's el now but we can optimize that later. makeREDOMTagString() { var redomTagStr = this.tagName if (this.idName) redomTagStr += "#"+this.idName; if (this.className) { redomTagStr += "."+this.className.replace(/(^\s+)|(\s+$)/,'').replace(/\s+/g,'.') } return redomTagStr || ''; } getUnnamedCB(force) { if (this.unnamedCB.length == 0) return (force) ? ()=>{} : null; else if (this.unnamedCB.length == 1) return this.unnamedCB[0]; else return (...p)=>{ for (let i=0; i<this.unnamedCB.length; i++) this.unnamedCB[i](...p) } } } // Form a parent<->child relationship between DOM Elements. // This is a wrapper over the <domNode>.appendChild/insertBefore methods. It adds two features. // 1. The child content can be specified in more flexible ways // 2. It maintains named links in the prent to the child under these circumstances // * If a name is available for a child node // * the parent has the [bgComponent] key (indicating that it is opting into this behavior) // // ChildContent Types: // Several types of children content are supported. // component : object(w/.el) : any JS object with a 'el' property (el should be a DOM Node) // DOMNode : object(w/.nodeType) : DOMNodes are identified by having a 'nodeType' property // plain text: string(s[0]!="<") : Plain text will be appended as a text node. // html text : string(s[0]=="<") : HTML test will be converted to a component whose outerHTML is the provided text // multiple Children : array : multiple children can be given in an array. Each array element can be any of the // supported types including a nested array. Array nesting will not affect how the child // hiearchy is built -- all children will be traversed and added to this component directly. // The one difference is if name is specified and content is an array, the <name> property // created in the parent will be an array with elements poiting to the children. Any // children in the array that have a name property will have a reference added as that // name reardless of whether the array itself is named. Typically, arrays will not be named // and there is no difference between adding the children individually or within an array. // ComponentUnmount: // To avoid memory leaks, ComponentMount and ComponentUnmount should be called in matching pairs. If you call ComponentMount // then you should call ComponentUnmount to undo the cyclic references when the dom element is no longer needed. // The exception to this is if the child is unamed. In that case no extra references are formed and the discarding the parent // DOM node is sufficient. // // Params: // name:string : the variable-like name of the child. If not provided, <childContent>.name will be used. If that // does not exist, childContent will be unamed with regard to its parent. // The special name 'unnamed' is recognized as no name being passed. This could be useful to avoid ambiguity // childContent:<multi> : the content to be added to this component's children. It can be given in any of the types described above. // insertBefore:object : (optional) the existing child to insert childContent before as a DOM Node or component object. // Default is append to end fo existing // Usage: // The name parameter is optional but for readability, it is the second parameter if provided. // Note that if first param is a single word content and the insertBefore is specified it will incorrectly be interpreted as // Form1. You can pass 'unnamed' as the first paramter avoid ambiguity and still result in an unnamed child. // Form1: ComponentMount(<parent>, <name>, <childContent> [,<insertBefore>]) // Form2: ComponentMount(<parent>, <childContent> [,<insertBefore>]) export function ComponentMount(parent, p1, p2, p3) { // detect form1 and form2 var name, childContent, insertBefore; // if p3 is specified the user maust have called with 3 params so it must be form 1 // The only other form 1 cases is when p2 is specified and p1 is a valid name // When p1 is content that happens to also be a valid name and insertBefore is specified, it will be incorrectly classified. const p2Specified = (typeof p2 != 'undefined'); const p3Specified = (typeof p3 != 'undefined'); const p1CanBeAName= (typeof p1 == 'string' && reVarName.test(p1)); if ((p3Specified) || (p2Specified && p1CanBeAName)) { name = p1; if (name == "unnamed") name=''; childContent = p2 insertBefore = p3 } else { childContent = p1 insertBefore = p2 } // when specifying children content, sometimes its convenient to allow the expression to result null, so just ignore this case if (childContent == null) return; switch (typeof childContent) { // ChildContent can be null but not undefined. This is either a logic error in Form1/Form2 detection or the caller explicitly // passed 'undefined' as the content case 'undefined': console.assert(false, "ChildContent can be null but not undefined."); case 'string': var element; if (reHTMLContent.test(childContent)) { // it begins with an html tag so interpret it as html element = el(''); element.innerHTML = childContent.trim(); element = element.firstChild; } else element = text(childContent); childContent = element; break; case 'object': // iterate an array of children and recursively add them if (Array.isArray(childContent)) { if (name && parent[bgComponent]) parent[name]=[]; for (var i =0; i<childContent.length; i++) { // recurse explicitly with all the params to avoid any ambiguity -- if insertBefore is undefined, pass null var mountedChild = ComponentMount(parent, null, childContent[i], insertBefore || null); if (name && parent[bgComponent]) parent[name][i] = mountedChild; } return childContent; } break; default: console.assert(false, "Invalid arguments. ChildContent needs to be an object, array or string", {childContent:childContent,p1:p1,p2:p2,p3:p3, type:typeof childContent}); } // do the work redomMount(parent, childContent, insertBefore); // if name was not explicitly passed in, see if we can get it from the content if (!name && typeof childContent == 'object' && childContent.name) name = childContent.name; if (parent[bgComponent]) { if (name) { parent[name] = childContent; childContent.name = name; if (!parent.mounted) parent.mounted = []; parent.mounted.push(name); var elNode = getEl(childContent); if (elNode && elNode.classList) elNode.classList.add(name); } else { if (!parent.mountedUnamed) parent.mountedUnamed = []; parent.mountedUnamed.push(childContent); } } if (childContent[bgComponent]) { if (childContent.parent) console.log('replacing childContent.parent = ',childContent.parent, ' with ', parent); childContent.parent = parent; } return childContent; } // Tear down the parent<->child relationship that ComponentMount created and remove the DOM child relationship also. export function ComponentUnmount(parent, name) { var child = parent[name]; assert(!!child, "unmounting a child with ComponentUnmount that does not exist in the parent") redomUnmount(parent, parent[name]); var i = parent.mounted.indexOf(name); if (i != -1) parent.mounted.splice(i,1); if (parent[name].parent === parent) delete parent[name].parent; delete parent[name]; return child; }