solarite 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Solarite.d.ts CHANGED
@@ -10,14 +10,17 @@ export interface RenderOptions {
10
10
  ids?: boolean;
11
11
  render?: boolean;
12
12
 
13
- /** Defaults to true: bubbling events (click, input, etc.) dispatch from one document-level
14
- * listener instead of addEventListener per element - much faster creation and teardown
15
- * of large lists. Pass false to bind every event directly, or an array to delegate only
16
- * the listed event names. Non-bubbling events always bind directly. Note: delegated
17
- * handlers run when the event reaches the document, so manual stopPropagation() on an
18
- * ancestor suppresses them, and manually added ancestor listeners fire first. A
19
- * programmatically dispatched non-bubbling event won't reach delegated handlers. */
20
- eventDelegation?: boolean | string[];
13
+ /** Defaults to true: bubbling events (click, input, etc.) dispatch from one listener on
14
+ * the component's root element instead of addEventListener per element - much faster
15
+ * creation and teardown of large lists. Pass false to bind every event directly, or an
16
+ * array to delegate only the listed event names. Pass 'document' to also register the
17
+ * dispatcher on the document, so handlers keep firing on nodes that get re-parented
18
+ * outside the component (e.g. a toolbar a dock parks in its own chrome). Non-bubbling
19
+ * events always bind directly. Note: delegated handlers run when the event bubbles to
20
+ * the root (or document), so stopPropagation() in a manually added listener on an element
21
+ * in between suppresses them, and such manual listeners fire first. A programmatically
22
+ * dispatched non-bubbling event won't reach delegated handlers. */
23
+ eventDelegation?: boolean | string[] | 'document';
21
24
  }
22
25
 
23
26
  /**
@@ -37,11 +40,17 @@ declare function h(): (htmlStrings: TemplateStringsArray, ...exprs: any[]) => No
37
40
 
38
41
  declare namespace h {
39
42
  /** Render a list, reusing each item's DOM while the item is the SAME object; replace an item
40
- * (don't mutate it) to re-render it. fn runs only for new items. No deps: identity is the dep. */
41
- function map<T>(items:T[], fn:(item:T) => Template): Template[];
43
+ * (don't mutate it) to re-render it. fn runs only for rows the reconciler can't match.
44
+ * No deps: identity is the dep. Returns a MappedList, not an array — put it straight into
45
+ * a template expression; it's iterable if you really need the templates. */
46
+ function map<T>(items:T[], fn:(item:T) => Template): MappedList<T>;
42
47
 
43
48
  /** Alias of h.map with a name that flags the immutability contract at the call site. */
44
- function immutableMap<T>(items:T[], fn:(item:T) => Template): Template[];
49
+ function immutableMap<T>(items:T[], fn:(item:T) => Template): MappedList<T>;
50
+
51
+ /** A selection that writes only the two rows it affects, with no render() call. Bind with
52
+ * when() as a whole attribute value; move it with set(). */
53
+ function selector<K = any>(key?: K | null): Selector<K>;
45
54
  }
46
55
 
47
56
  /** Tagged template literal for SVG markup and SVG child fragments. */
@@ -54,7 +63,26 @@ export {h, svg};
54
63
  /**
55
64
  * Solarite provides more features if your web component extends Solarite instead of HTMLElement. */
56
65
  export class Solarite extends HTMLElement {
57
- constructor(attribs?: Record<string, any> | null);
66
+ /**
67
+ * Fill in and fix up the attribs object a component's constructor receives, so the component
68
+ * can then copy those values onto its own fields, e.g. with ObjectUtil.assign(this, attribs).
69
+ *
70
+ * 1. If attribs is an empty object, fill it with the attributes on the DOM element.
71
+ * This happens when the browser creates the element from plain html, because then nothing
72
+ * calls the constructor with arguments. Attribute names convert from dash-case to
73
+ * camelCase, and `${...}` values are parsed from JSON.
74
+ * 2. If types is given, convert attribs values from strings to those types. Attribute values
75
+ * written as literal text always arrive as strings, whether from plain html or from an h()
76
+ * template. types maps a field name to Number, Boolean, String, Date, or any function
77
+ * taking the string and returning a value. Boolean is true for every string except
78
+ * 'false' and '0', so a bare attribute like `<select-box-3 editable>` becomes true.
79
+ * Values that are already not strings, like a `${true}` template expression, are left alone.
80
+ *
81
+ * This runs before the subclass initializes its fields and renders, so converted values are
82
+ * right the first time, even for fields that change what render() builds. This constructor
83
+ * can't copy attribs onto fields itself, because subclass field initializers run after it
84
+ * finishes and would overwrite them; that's why the subclass does the final assign. */
85
+ constructor(attribs?: Record<string, any> | null, types?: Record<string, Function> | null);
58
86
  render(attribs?: Record<string, any>, changed?:boolean): void;
59
87
  renderFirstTime(): void;
60
88
  connectedCallback(): void;
@@ -88,6 +116,54 @@ export function getEventBinding(node: Node, key: string): EventBinding | undefin
88
116
  * and '0', so a bare attribute reads as true. Field names in `ignore` are skipped. */
89
117
  export function assignAttributes(dest: HTMLElement, types?: Record<string, Function>, ignore?: string[]): void;
90
118
 
119
+ /**
120
+ * Convert an attribute string with the given converter: Number, Boolean, String, Date,
121
+ * or any function taking the string and returning a value. Boolean is true for any string
122
+ * except 'false' and '0', so a bare attribute reads as true. No converter returns the
123
+ * string unchanged. */
124
+ export function convertType(value: string, type?: Function): any;
125
+
126
+ /** What h.map() returns: the source items plus the callback that builds one item's Template,
127
+ * so the reconciler can recognize an unchanged row by the item it was built from. Iterating it
128
+ * yields the Templates, building each one, for code that needs an array. */
129
+ export class MappedList<T = any> {
130
+ items: T[];
131
+ fn: (item: T) => Template;
132
+ constructor(items: T[], fn: (item: T) => Template);
133
+ [Symbol.iterator](): IterableIterator<Template>;
134
+ }
135
+
136
+ /** What h.selector() returns. Holds one selected key; when() binds an attribute to whether a
137
+ * row's key is that one, and set() moves the selection by writing only the rows that change. */
138
+ export class Selector<K = any> {
139
+ constructor(key?: K | null);
140
+
141
+ /** The selected key, or null. */
142
+ readonly key: K | null;
143
+
144
+ /** Bind an attribute to whether key is the selected one. Must supply the WHOLE attribute
145
+ * value, on the row's own root element, and the rows must be keyed — each of those throws
146
+ * otherwise. An off value of '' (the default) leaves no attribute rather than an empty one. */
147
+ when(key: K, on: any, off?: any): SelectorRef<K>;
148
+
149
+ /** Move the selection, writing at most two attributes and calling no render().
150
+ * Pass null to deselect. */
151
+ set(key: K | null): void;
152
+ }
153
+
154
+ /** What when() returns. A Selector owns exactly TWO of these — one meaning "this row is the
155
+ * selected one" and one meaning "it isn't" — rather than one per key, so drawing a row costs no
156
+ * allocation. The stable identity is also what lets an unchanged row skip its write on a
157
+ * re-render: a row's expression changes identity exactly when its selectedness does. */
158
+ export class SelectorRef<K = any> {
159
+ readonly selector: Selector<K>;
160
+
161
+ /** True for the selector's "selected" singleton, false for its "unselected" one. */
162
+ readonly selected: boolean;
163
+
164
+ value(): any;
165
+ }
166
+
91
167
  export class Template {
92
168
  exprs: any[];
93
169
  html: string[];
@@ -126,7 +202,7 @@ export const SolariteUtil: {
126
202
  bindStyles(style: HTMLStyleElement, root: HTMLElement): void;
127
203
  camelToDashes(str: string): string;
128
204
  dashesToCamel(str: string): string;
129
- defineClass(Class: typeof HTMLElement, tagName?: string | null): void;
205
+ defineClass(Class: typeof HTMLElement, tagName?: string | null): string;
130
206
  isIterable(obj: any): boolean;
131
207
  trimEmptyNodes(nodes: NodeList | Node[]): Node[];
132
208
  [key: string]: any;
package/src/Solarite.js CHANGED
@@ -7,15 +7,24 @@ JavasCript UI library
7
7
  @copyright Vorticode LLC
8
8
  https://vorticode.github.io/solarite/ */
9
9
  import h from './h.js';
10
+ import {convertType} from './assignAttributes.js';
10
11
  export default h;
11
12
  export {default as delve} from './delve.js';
12
13
  export {default as Template} from './Template.js';
14
+ export {default as MappedList} from './MappedList.js';
15
+ export {default as Selector, SelectorRef} from './Selector.js';
13
16
  export {default as toEl} from './toEl.js';
14
- export {assignAttributes} from './assignAttributes.js';
17
+ export {assignAttributes, convertType} from './assignAttributes.js';
15
18
  export {svg} from './h.js';
16
19
  export {getEventBinding} from './PathToAttribValue.js';
17
20
  export {Fragment} from './jsx.js';
18
21
 
22
+ // Internals the JSX runtime needs, exported so dist/jsx-runtime.js can be built as a SEPARATE
23
+ // module that shares this bundle rather than bundling its own copy. They must be shared, not
24
+ // duplicated: PathToAttribs tests `instanceof JsxAttr`, which fails across two copies of the
25
+ // class. Not part of the documented API — jsx-runtime is the supported entry point.
26
+ export {jsxToTemplate as internalJsxToTemplate, JsxAttr as InternalJsxAttr} from './jsx.js';
27
+
19
28
  // Experimental:
20
29
  //--------------
21
30
  export {default as Globals} from './Globals.js';
@@ -25,7 +34,7 @@ export {default as SolariteUtil} from './Util.js';
25
34
  //--------------
26
35
  export {default as h} from './h.js'; // Named exports for h() are deprecated.
27
36
 
28
- // HtmlParser, NodeGroup, and Shell are internal; tests import them directly from their modules.
37
+ // NodeGroup and Shell are internal; tests import them directly from their modules.
29
38
 
30
39
 
31
40
 
@@ -65,42 +74,46 @@ let HTMLElementAutoDefine = new Proxy(HTMLElement, {
65
74
  export class Solarite extends HTMLElementAutoDefine {
66
75
 
67
76
  /**
68
- * @param attribs {?Record<string, any>} */
69
- constructor(attribs=null) {
77
+ * Fill in and fix up the attribs object a component's constructor receives, so the component
78
+ * can then copy those values onto its own fields, e.g. with ObjectUtil.assign(this, attribs).
79
+ *
80
+ * 1. If attribs is an empty object, fill it with the attributes on the DOM element.
81
+ * This happens when the browser creates the element from plain html, because then nothing
82
+ * calls the constructor with arguments. Attribute names convert from dash-case to
83
+ * camelCase, and `${...}` values are parsed from JSON.
84
+ * 2. If types is given, convert attribs values from strings to those types. Attribute values
85
+ * written as literal text always arrive as strings, whether from plain html or from an h()
86
+ * template. types maps a field name to Number, Boolean, String, Date, or any function
87
+ * taking the string and returning a value. Boolean is true for every string except
88
+ * 'false' and '0', so a bare attribute like `<select-box-3 editable>` becomes true.
89
+ * Values that are already not strings, like a `${true}` template expression, are left alone.
90
+ *
91
+ * This runs before the subclass initializes its fields and renders, so converted values are
92
+ * right the first time, even for fields that change what render() builds. This constructor
93
+ * can't copy attribs onto fields itself, because subclass field initializers run after it
94
+ * finishes and would overwrite them; that's why the subclass does the final assign.
95
+ * @param attribs {?Record<string, any>}
96
+ * @param types {?Record<string, Function>} */
97
+ constructor(attribs=null, types=null) {
70
98
  super();
71
99
 
72
100
  if (attribs) {
73
101
  if (typeof attribs !== 'object')
74
- throw new Error('First argument to custom element constructor must be an object.');
102
+ throw new Error('First argument must be an object.');
75
103
 
76
104
  // 1. Populate attribs if it's an empty object.
77
- if (attribs && !Object.keys(attribs).length) {
105
+ if (!Object.keys(attribs).length) {
78
106
  let attribs2 = Solarite.getAttribs(this);
79
107
  for (let name in attribs2) {
80
108
  attribs[name] = attribs2[name];
81
109
  }
82
110
  }
83
111
 
84
- // 2. Populate fields from attribs.
85
- // This does nothing because the fields are overwritten by the child class after this super() constructor executes.
86
- //for (let name in attribs || {}) {
87
- // if (name in this) {
88
- // const descriptor = Object.getOwnPropertyDescriptor(this, name);
89
- // if (!descriptor || descriptor.writable || descriptor.set)
90
- // this[name] = attribs[name];
91
- // }
92
- //}
112
+ // 2. Convert string values to the types the component declares.
113
+ for (let name in types || {})
114
+ if (typeof attribs[name] === 'string')
115
+ attribs[name] = convertType(attribs[name], types[name]);
93
116
  }
94
-
95
- // 3. Wrap render function so it always provides the attribs argument.
96
- // Disabled because this gives us strings for attribute values when we call render manually.
97
- // Instead of values given from ${...} expressions.
98
- // let originalRender = this.render;
99
- // this.render = (attribs, changed=true) => {
100
- // if (!attribs) // If we have to look up the attribs, we don't know if they changed or not.
101
- // attribs = Solarite.getAttribs(this);
102
- // originalRender.call(this, attribs, changed);
103
- // }
104
117
  }
105
118
 
106
119
  'render'() {
package/src/Template.js CHANGED
@@ -53,7 +53,7 @@ export default class Template {
53
53
 
54
54
  //this.trace = new Error().stack.split(/\n/g)
55
55
 
56
- //#IFDEV
56
+ //#IFDEBUG
57
57
  assert(Array.isArray(htmlStrings))
58
58
  assert(Array.isArray(exprs))
59
59
 
@@ -78,8 +78,12 @@ export default class Template {
78
78
  if (!ng) {
79
79
  ng = new RootNodeGroup(this, null, el, options);
80
80
  if (!el) // null if it's a standalone elment.
81
- el = ng.getRootNode();
82
- Globals.rootNodeGroups.set(el, ng); // All tests still pass if this is commented out!
81
+ el = ng.getRootEl();
82
+
83
+ // RootNodeGroup.instantiate() ends by registering itself under its own rootEl, which
84
+ // is the element we were given, or -- when we were given none -- the very element
85
+ // getRootEl() just handed back. Registering it a second time here stored the same
86
+ // group under the same key.
83
87
  }
84
88
 
85
89
  // Make sure the expresion count matches match the Path "hole" count.
@@ -94,8 +98,13 @@ export default class Template {
94
98
  // If we didn't just create it, we need to render it.
95
99
  if (this.html?.length === 1 && !this.html[0]) // An empty string.
96
100
  el.innerHTML = ''; // Fast path for empty component.
97
- else
98
- ng.applyExprs(this.exprs);
101
+ else {
102
+ // A component renders the same template every time, so hand over the expressions it
103
+ // applied last time; paths that can prove an unchanged expression is a no-op skip.
104
+ let last = ng.template;
105
+ ng.applyExprs(this.exprs, true, last !== this && last.html === this.html ? last.exprs : null);
106
+ ng.template = this;
107
+ }
99
108
 
100
109
  return el;
101
110
  }
@@ -125,9 +134,13 @@ export default class Template {
125
134
  export function templatesSame(a, b) {
126
135
  if (a.html === b.html && a.svgMode === b.svgMode) {
127
136
  let ae = a.exprs, be = b.exprs;
128
- for (let i=0; i<ae.length; i++)
129
- if (!exprSame(ae[i], be[i]))
137
+ // Most expressions are identical between renders, so test that here rather than paying
138
+ // a call into exprSame() to learn it.
139
+ for (let i=0; i<ae.length; i++) {
140
+ let x = ae[i], y = be[i];
141
+ if (x !== y && !exprSame(x, y))
130
142
  return false;
143
+ }
131
144
  return true;
132
145
  }
133
146
 
package/src/Util.js CHANGED
@@ -40,13 +40,15 @@ let Util = {
40
40
  // Don't clobber a non-element value. For a simple (non-nested) id this covers two cases:
41
41
  // an inherited/built-in property like `title` or `style`, or an own property that already
42
42
  // holds a non-Node value. A previously-bound element (a Node) is fine to re-assign.
43
+ // This can only fail on a mistake in the component's own template, so a developer meets it
44
+ // the first time the component renders and never again at runtime. It nonetheless SHIPS,
45
+ // and deliberately: debug-strip blocks are removed from dist/Solarite.js, which is what
46
+ // npm serves, so hiding it there would delete it for everyone, not only for production.
43
47
  if (!id.includes('.')) {
44
48
  let existing = root[id];
45
49
  let isInherited = (id in root) && !Object.hasOwn(root, id);
46
50
  if (!existing?.nodeType && (existing != null || isInherited))
47
- throw new Error(`${root.constructor.name}.${id} can't be a reference to ` +
48
- `<${el.tagName.toLowerCase()} id="${id}"> because it would clobber an existing ` +
49
- `${isInherited ? 'built-in ' : ''}property. Rename the id or the property.`);
51
+ throw new Error(`Solarite: id="${id}" would overwrite an existing ${root.constructor.name} property.`);
50
52
  }
51
53
 
52
54
  delve(root, id.split(/\./g), el);
@@ -65,29 +67,29 @@ let Util = {
65
67
  bindStyles(style, root) {
66
68
 
67
69
  let tagName = root.tagName.toLowerCase();
68
- let styleId, attribSelector;
70
+
71
+ // A global style is scoped by tag name alone, so it needs no attribute in the selector.
72
+ let attribSelector = '';
69
73
 
70
74
  if (style.hasAttribute('global') || style.hasAttribute('data-global')) {
71
- styleId = tagName;
72
- attribSelector = '';
73
- let doc = Globals.doc || root.ownerDocument || document;
74
- if (!doc.head.querySelector(`style[data-style="${styleId}"]`)) {
75
- doc.head.append(style)
76
- style.setAttribute('data-style', styleId);
77
- }
78
- else // TODO: Make sure the style has no expressions.
75
+ let head = Globals.doc.head;
76
+ if (head.querySelector(`style[data-style="${tagName}"]`))
77
+ // TODO: Make sure the style has no expressions.
79
78
  style.remove(); // already in the head.
79
+ else {
80
+ head.append(style)
81
+ style.setAttribute('data-style', tagName);
82
+ }
80
83
  }
81
84
  else {
82
85
  let styleId = root.getAttribute('data-style');
83
86
  if (!styleId) {
84
- // Keep track of one style id for each class.
87
+ // Keep track of one style id for each class. Reading the static walks up to a parent
88
+ // class's counter if this class has never been styled, but the assignment always lands
89
+ // on this class, so each class then counts on from where its parent left off.
85
90
  // TODO: Put this outside the class in a map, so it doesn't conflict with static properties.
86
- if (!root.constructor.styleId)
87
- root.constructor.styleId = 1;
88
- styleId = root.constructor.styleId++;
89
-
90
- root.setAttribute('data-style', styleId);
91
+ let Class = root.constructor;
92
+ root.setAttribute('data-style', styleId = Class.styleId = (Class.styleId || 0) + 1);
91
93
  }
92
94
 
93
95
  attribSelector = `[data-style="${styleId}"]`;
@@ -97,7 +99,19 @@ let Util = {
97
99
  for (let child of style.childNodes) {
98
100
  if (child.nodeType === 3) {
99
101
  let oldText = child.textContent;
100
- let newText = oldText.replace(/:host(?=[^a-z0-9_])/gi, `${tagName}${attribSelector}`)
102
+
103
+ // One pass rewrites both forms of the selector:
104
+ // 1. The functional form ':host(X)' — the host element when it also matches X — unwraps
105
+ // so X sits right after the scoped name: tag[data-style="1"]X. X may hold one
106
+ // nested group like ':not(.open)'; deeper parentheses can't be paired by a regex,
107
+ // so such an X is left as written rather than half-rewritten into a selector the
108
+ // browser would discard silently.
109
+ // 2. Plain ':host'. The lookahead turns down longer names (':host-context') and '(',
110
+ // which only follows ':host' when alternative 1 already gave up on it, and accepts
111
+ // the end of the text node, where an expression may have split a dynamic style.
112
+ let newText = oldText.replace(
113
+ /:host(?:\(((?:[^()]|\([^()]*\))*)\)|(?![-a-z0-9_(]))/gi,
114
+ `${tagName}${attribSelector}$1`);
101
115
  if (oldText !== newText)
102
116
  child.textContent = newText;
103
117
  }
@@ -118,17 +132,15 @@ let Util = {
118
132
  * 'UIForm' => 'ui-form'
119
133
  * 'A100' => 'a-100' */
120
134
  camelToDashes(str) {
121
- // Convert any capital letter that is preceded by a lowercase letter or number to lowercase and precede with a dash.
122
- str = str.replace(/([a-z0-9])([A-Z])/g, '$1-$2');
123
-
124
- // Convert any capital letter that is followed by a lowercase letter or number to lowercase and precede with a dash.
125
- str = str.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2');
126
-
127
- // Convert any number that is preceded by a lowercase or uppercase letter to be preceded by a dash.
128
- str = str.replace(/([a-zA-Z])([0-9])/g, '$1-$2');
129
-
130
- // Convert all the remaining capital letters to lowercase.
131
- return str.toLowerCase();
135
+ // One pass finds all three dash positions. Each alternative matches only the character
136
+ // *before* the boundary and uses a lookahead for what follows, so the following character
137
+ // is never consumed and can still start the next boundary. That's what lets the three
138
+ // rules interleave in a single scan the way three sequential replaces used to:
139
+ // 1. a lowercase letter or digit before a capital ('ProperName').
140
+ // 2. a capital before a capital+lowercase pair, i.e. the last capital of a run ('HTMLElement').
141
+ // 3. a letter before a digit ('A100').
142
+ // '$&-' appends the dash after the matched character, then everything folds to lowercase.
143
+ return str.replace(/[a-z0-9](?=[A-Z])|[A-Z](?=[A-Z][a-z])|[a-zA-Z](?=\d)/g, '$&-').toLowerCase();
132
144
  },
133
145
 
134
146
  /**
@@ -144,13 +156,24 @@ let Util = {
144
156
  return str.replace(/-([a-z])/g, g => g[1].toUpperCase());
145
157
  },
146
158
 
159
+ /**
160
+ * Register Class as a custom element, unless it's registered already.
161
+ * @param Class {typeof HTMLElement}
162
+ * @param tagName {?string} Name to register under. Defaults to the dashed form of the class name.
163
+ * @return {string} The tag name Class is registered under, whether we just registered it or it
164
+ * was already in the registry under some other name. Callers that emit markup for the class
165
+ * use this instead of re-deriving the name, which guesses wrong for any class registered
166
+ * under a name that isn't camelToDashes(Class.name). */
147
167
  defineClass(Class, tagName) {
148
- if (!customElements[getName](Class)) { // If not previously defined.
149
- tagName = tagName || Util.camelToDashes(Class.name)
150
- if (!tagName.includes('-')) // Browsers require that web components always have a dash in the name.
151
- tagName += '-element';
152
- customElements[define](tagName, Class)
153
- }
168
+ let defined = customElements[getName](Class);
169
+ if (defined) // Previously defined.
170
+ return defined;
171
+
172
+ tagName = tagName || Util.camelToDashes(Class.name)
173
+ if (!tagName.includes('-')) // Browsers require that web components always have a dash in the name.
174
+ tagName += '-element';
175
+ customElements[define](tagName, Class)
176
+ return tagName;
154
177
  },
155
178
 
156
179
  /**
@@ -177,8 +200,8 @@ let Util = {
177
200
  return node.value; // String
178
201
  },
179
202
 
180
- isEvent(attrName) {
181
- return attrName.startsWith('on') && attrName in Globals.div;
203
+ isEvent(attribName) {
204
+ return attribName.startsWith('on') && attribName in Globals.div;
182
205
  },
183
206
 
184
207
  /**
@@ -215,16 +238,13 @@ let Util = {
215
238
  * @returns {Object} */
216
239
  splitAttribs(str) {
217
240
  let result = {};
218
- let attrs = (str + '') // Split string into multiple attributes.
219
- .split(/([\w-]+\s*=\s*(?:"[^"]*"|'[^']*'|\S+))/g)
220
- .map(text => text.trim())
221
- .filter(text => text.length);
222
-
223
- for (let attr of attrs) {
224
- let [name, value] = attr.split(/\s*=\s*/); // split on first equals.
225
- value = (value || '').replace(/^(['"])(.*)\1$/, '$2'); // trim value quotes if they match.
226
- result[name] = value;
227
- }
241
+
242
+ // One scan collects every name and its value. The value is optional so a boolean attribute
243
+ // written on its own ('disabled') still lands in the result with an empty value, and the three
244
+ // value alternatives capture *inside* the quotes so no separate quote-trimming pass is needed.
245
+ // Whatever doesn't look like an attribute name is skipped rather than becoming a bogus key.
246
+ (str + '').replace(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g,
247
+ (_, name, dq, sq, bare) => result[name] = dq ?? sq ?? bare ?? '');
228
248
 
229
249
  return result;
230
250
  },
@@ -262,19 +282,14 @@ let Util = {
262
282
  * @param nodes {Node[]|NodeList}
263
283
  * @returns {Node[]} */
264
284
  trimEmptyNodes(nodes) {
265
- const shouldTrimNode = node =>
266
- node.nodeType !== Node.ELEMENT_NODE &&
267
- (node.nodeType !== Node.TEXT_NODE || node.textContent.trim() === '');
268
-
269
- // Convert nodeList to an array for easier manipulation
270
- const result = [...nodes]
285
+ // nodeType 1 is an element and 3 is a text node; the literals are what Node.ELEMENT_NODE
286
+ // and Node.TEXT_NODE are defined as, and they cost a fraction of the bytes.
287
+ let isEmpty = node => node.nodeType !== 1 && (node.nodeType !== 3 || !node.textContent.trim());
271
288
 
272
- // Trim from the start
273
- while (result.length > 0 && shouldTrimNode(result[0]))
289
+ let result = [...nodes]; // A NodeList can't shift() or pop().
290
+ while (result.length && isEmpty(result[0]))
274
291
  result.shift();
275
-
276
- // Trim from the end
277
- while (result.length > 0 && shouldTrimNode(result[result.length - 1]))
292
+ while (result.length && isEmpty(result[result.length - 1]))
278
293
  result.pop();
279
294
 
280
295
  return result;
@@ -292,7 +307,7 @@ export default Util;
292
307
 
293
308
 
294
309
  // For debugging only
295
- //#IFDEV
310
+ //#IFDEBUG
296
311
  export function setIndent(items, level=1) {
297
312
  if (typeof items === 'string')
298
313
  items = items.split(/\r?\n/g)
package/src/assert.js CHANGED
@@ -1,7 +1,7 @@
1
1
 
2
2
  /*@__NO_SIDE_EFFECTS__*/
3
3
  export default function assert(val) {
4
- //#IFDEV
4
+ //#IFDEBUG
5
5
  if (!val) {
6
6
  //debugger;
7
7
  throw new Error('Assertion failed: ' + val);
@@ -1,14 +1,23 @@
1
- /*
2
- ┏┓ ┓ •
3
- ┗┓┏┓┃┏┓┏┓┓╋▗▖
4
- ┗┛┗┛┗┗┻╹ ╹╹┗
5
- JavaScript UI library
6
- @license MIT
7
- @copyright Vorticode LLC
8
- https://vorticode.github.io/solarite/ */
9
-
10
1
  import Util from "./Util.js";
11
2
 
3
+ /**
4
+ * Convert an attribute string with the given converter: Number, Boolean, String, Date,
5
+ * or any function taking the string and returning a value. Boolean is true for any string
6
+ * except 'false' and '0', so a bare attribute like `<my-timer auto-start>` reads as true.
7
+ * Date uses new Date(value). No converter returns the string unchanged. */
8
+ export function convertType(value, type) {
9
+ if (type === Date)
10
+ return new Date(value);
11
+ if (type === Boolean)
12
+ return !['false', '0'].includes(value);
13
+ // Number and String need no cases of their own: they're plain functions, so the custom
14
+ // branch below calls them correctly. Date and Boolean are the ones that can't fall through
15
+ // (Date without `new` returns a string; Boolean('false') is true).
16
+ if (type) // Number, String, or a custom string=>value function
17
+ return type(value);
18
+ return value;
19
+ }
20
+
12
21
  /**
13
22
  * Read an element's html attributes onto fields that already exist on the element.
14
23
  * Typically called from a web component constructor to support plain-html instantiation
@@ -43,16 +52,8 @@ export function assignAttributes(dest, types={}, ignore=[]) {
43
52
  dest[name] = JSON.parse(value.slice(2, -1));
44
53
 
45
54
  // 2. Cast the string with the converter named in `types`, if any.
46
- else if (type === Date)
47
- dest[name] = new Date(value);
48
- else if (type === Boolean)
49
- dest[name] = !['false', '0'].includes(value);
50
- else if (type === Number)
51
- dest[name] = Number(value);
52
- else if (type === String)
53
- dest[name] = String(value);
54
- else if (type) // custom string=>value function
55
- dest[name] = type(value);
55
+ else if (type)
56
+ dest[name] = convertType(value, type);
56
57
 
57
58
  // 3. No converter named: assign the raw string. But an empty value over a function/object
58
59
  // field is just the serialization residue of a template expression (functions render as