solarite 0.7.1 → 0.9.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,13 +10,18 @@ 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. */
13
+ /**
14
+ * Delegate bubbling events (default true). A handler is stored on its element instead of
15
+ * registered with addEventListener, and when an event of that type starts, a capture-phase
16
+ * listener on the component root and the document attaches a real listener to each element
17
+ * on the event's path that has one; the browser then dispatches normally. Much faster
18
+ * creation and teardown of large lists, with native ordering, stopPropagation(),
19
+ * currentTarget, non-bubbling events and moved elements all behaving as with
20
+ * addEventListener. The one difference: a delegated handler is attached when the event
21
+ * starts, so it runs after listeners other code added to the same element. Prefix an
22
+ * attribute with native: (native:onclick) to bind just that handler at render time.
23
+ * Pass false to bind every event directly, or an array to delegate only the listed event
24
+ * names. Non-bubbling events always bind directly. */
20
25
  eventDelegation?: boolean | string[];
21
26
  }
22
27
 
@@ -37,11 +42,17 @@ declare function h(): (htmlStrings: TemplateStringsArray, ...exprs: any[]) => No
37
42
 
38
43
  declare namespace h {
39
44
  /** 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[];
45
+ * (don't mutate it) to re-render it. fn runs only for rows the reconciler can't match.
46
+ * No deps: identity is the dep. Returns a MappedList, not an array — put it straight into
47
+ * a template expression; it's iterable if you really need the templates. */
48
+ function map<T>(items:T[], fn:(item:T) => Template): MappedList<T>;
42
49
 
43
50
  /** 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[];
51
+ function immutableMap<T>(items:T[], fn:(item:T) => Template): MappedList<T>;
52
+
53
+ /** A selection that writes only the two rows it affects, with no render() call. Bind with
54
+ * when() as a whole attribute value; move it with set(). */
55
+ function selector<K = any>(key?: K | null): Selector<K>;
45
56
  }
46
57
 
47
58
  /** Tagged template literal for SVG markup and SVG child fragments. */
@@ -54,7 +65,26 @@ export {h, svg};
54
65
  /**
55
66
  * Solarite provides more features if your web component extends Solarite instead of HTMLElement. */
56
67
  export class Solarite extends HTMLElement {
57
- constructor(attribs?: Record<string, any> | null);
68
+ /**
69
+ * Fill in and fix up the attribs object a component's constructor receives, so the component
70
+ * can then copy those values onto its own fields, e.g. with ObjectUtil.assign(this, attribs).
71
+ *
72
+ * 1. If attribs is an empty object, fill it with the attributes on the DOM element.
73
+ * This happens when the browser creates the element from plain html, because then nothing
74
+ * calls the constructor with arguments. Attribute names convert from dash-case to
75
+ * camelCase, and `${...}` values are parsed from JSON.
76
+ * 2. If types is given, convert attribs values from strings to those types. Attribute values
77
+ * written as literal text always arrive as strings, whether from plain html or from an h()
78
+ * template. types maps a field name to Number, Boolean, String, Date, or any function
79
+ * taking the string and returning a value. Boolean is true for every string except
80
+ * 'false' and '0', so a bare attribute like `<select-box-3 editable>` becomes true.
81
+ * Values that are already not strings, like a `${true}` template expression, are left alone.
82
+ *
83
+ * This runs before the subclass initializes its fields and renders, so converted values are
84
+ * right the first time, even for fields that change what render() builds. This constructor
85
+ * can't copy attribs onto fields itself, because subclass field initializers run after it
86
+ * finishes and would overwrite them; that's why the subclass does the final assign. */
87
+ constructor(attribs?: Record<string, any> | null, types?: Record<string, Function> | null);
58
88
  render(attribs?: Record<string, any>, changed?:boolean): void;
59
89
  renderFirstTime(): void;
60
90
  connectedCallback(): void;
@@ -88,6 +118,54 @@ export function getEventBinding(node: Node, key: string): EventBinding | undefin
88
118
  * and '0', so a bare attribute reads as true. Field names in `ignore` are skipped. */
89
119
  export function assignAttributes(dest: HTMLElement, types?: Record<string, Function>, ignore?: string[]): void;
90
120
 
121
+ /**
122
+ * Convert an attribute string with the given converter: Number, Boolean, String, Date,
123
+ * or any function taking the string and returning a value. Boolean is true for any string
124
+ * except 'false' and '0', so a bare attribute reads as true. No converter returns the
125
+ * string unchanged. */
126
+ export function convertType(value: string, type?: Function): any;
127
+
128
+ /** What h.map() returns: the source items plus the callback that builds one item's Template,
129
+ * so the reconciler can recognize an unchanged row by the item it was built from. Iterating it
130
+ * yields the Templates, building each one, for code that needs an array. */
131
+ export class MappedList<T = any> {
132
+ items: T[];
133
+ fn: (item: T) => Template;
134
+ constructor(items: T[], fn: (item: T) => Template);
135
+ [Symbol.iterator](): IterableIterator<Template>;
136
+ }
137
+
138
+ /** What h.selector() returns. Holds one selected key; when() binds an attribute to whether a
139
+ * row's key is that one, and set() moves the selection by writing only the rows that change. */
140
+ export class Selector<K = any> {
141
+ constructor(key?: K | null);
142
+
143
+ /** The selected key, or null. */
144
+ readonly key: K | null;
145
+
146
+ /** Bind an attribute to whether key is the selected one. Must supply the WHOLE attribute
147
+ * value, on the row's own root element, and the rows must be keyed — each of those throws
148
+ * otherwise. An off value of '' (the default) leaves no attribute rather than an empty one. */
149
+ when(key: K, on: any, off?: any): SelectorRef<K>;
150
+
151
+ /** Move the selection, writing at most two attributes and calling no render().
152
+ * Pass null to deselect. */
153
+ set(key: K | null): void;
154
+ }
155
+
156
+ /** What when() returns. A Selector owns exactly TWO of these — one meaning "this row is the
157
+ * selected one" and one meaning "it isn't" — rather than one per key, so drawing a row costs no
158
+ * allocation. The stable identity is also what lets an unchanged row skip its write on a
159
+ * re-render: a row's expression changes identity exactly when its selectedness does. */
160
+ export class SelectorRef<K = any> {
161
+ readonly selector: Selector<K>;
162
+
163
+ /** True for the selector's "selected" singleton, false for its "unselected" one. */
164
+ readonly selected: boolean;
165
+
166
+ value(): any;
167
+ }
168
+
91
169
  export class Template {
92
170
  exprs: any[];
93
171
  html: string[];
@@ -107,7 +185,7 @@ export function delve(obj: object, path: string[], createVal?: any): any;
107
185
  * Internal utilities and state. */
108
186
  export const Globals: {
109
187
  connected: WeakSet<HTMLElement>;
110
- currentSlotChildren: any[] | null;
188
+ currentSlotChildren: {Constructor: Function, nodes: Node[]} | null;
111
189
  div: HTMLDivElement;
112
190
  doc: Document;
113
191
  elementClasses: {[key: string]: typeof Node};
@@ -126,7 +204,7 @@ export const SolariteUtil: {
126
204
  bindStyles(style: HTMLStyleElement, root: HTMLElement): void;
127
205
  camelToDashes(str: string): string;
128
206
  dashesToCamel(str: string): string;
129
- defineClass(Class: typeof HTMLElement, tagName?: string | null): void;
207
+ defineClass(Class: typeof HTMLElement, tagName?: string | null): string;
130
208
  isIterable(obj: any): boolean;
131
209
  trimEmptyNodes(nodes: NodeList | Node[]): Node[];
132
210
  [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
@@ -1,6 +1,12 @@
1
1
  import Globals from "./Globals.js";
2
2
  import delve from "./delve.js";
3
3
 
4
+ /**
5
+ * Prefix that asks for a handler to bypass event delegation: `<button native:onclick=\${...}>`
6
+ * is bound with addEventListener at render time, taking its normal place in the browser's own
7
+ * dispatch order. Shared by Util.isEvent() and PathToEvent, which strips it. */
8
+ export const nativeEventPrefix = 'native:';
9
+
4
10
  let Util = {
5
11
 
6
12
  /**
@@ -40,13 +46,15 @@ let Util = {
40
46
  // Don't clobber a non-element value. For a simple (non-nested) id this covers two cases:
41
47
  // an inherited/built-in property like `title` or `style`, or an own property that already
42
48
  // holds a non-Node value. A previously-bound element (a Node) is fine to re-assign.
49
+ // This can only fail on a mistake in the component's own template, so a developer meets it
50
+ // the first time the component renders and never again at runtime. It nonetheless SHIPS,
51
+ // and deliberately: debug-strip blocks are removed from dist/Solarite.js, which is what
52
+ // npm serves, so hiding it there would delete it for everyone, not only for production.
43
53
  if (!id.includes('.')) {
44
54
  let existing = root[id];
45
55
  let isInherited = (id in root) && !Object.hasOwn(root, id);
46
56
  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.`);
57
+ throw new Error(`Solarite: id="${id}" would overwrite an existing ${root.constructor.name} property.`);
50
58
  }
51
59
 
52
60
  delve(root, id.split(/\./g), el);
@@ -65,29 +73,29 @@ let Util = {
65
73
  bindStyles(style, root) {
66
74
 
67
75
  let tagName = root.tagName.toLowerCase();
68
- let styleId, attribSelector;
76
+
77
+ // A global style is scoped by tag name alone, so it needs no attribute in the selector.
78
+ let attribSelector = '';
69
79
 
70
80
  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.
81
+ let head = Globals.doc.head;
82
+ if (head.querySelector(`style[data-style="${tagName}"]`))
83
+ // TODO: Make sure the style has no expressions.
79
84
  style.remove(); // already in the head.
85
+ else {
86
+ head.append(style)
87
+ style.setAttribute('data-style', tagName);
88
+ }
80
89
  }
81
90
  else {
82
91
  let styleId = root.getAttribute('data-style');
83
92
  if (!styleId) {
84
- // Keep track of one style id for each class.
93
+ // Keep track of one style id for each class. Reading the static walks up to a parent
94
+ // class's counter if this class has never been styled, but the assignment always lands
95
+ // on this class, so each class then counts on from where its parent left off.
85
96
  // 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);
97
+ let Class = root.constructor;
98
+ root.setAttribute('data-style', styleId = Class.styleId = (Class.styleId || 0) + 1);
91
99
  }
92
100
 
93
101
  attribSelector = `[data-style="${styleId}"]`;
@@ -97,7 +105,19 @@ let Util = {
97
105
  for (let child of style.childNodes) {
98
106
  if (child.nodeType === 3) {
99
107
  let oldText = child.textContent;
100
- let newText = oldText.replace(/:host(?=[^a-z0-9_])/gi, `${tagName}${attribSelector}`)
108
+
109
+ // One pass rewrites both forms of the selector:
110
+ // 1. The functional form ':host(X)' — the host element when it also matches X — unwraps
111
+ // so X sits right after the scoped name: tag[data-style="1"]X. X may hold one
112
+ // nested group like ':not(.open)'; deeper parentheses can't be paired by a regex,
113
+ // so such an X is left as written rather than half-rewritten into a selector the
114
+ // browser would discard silently.
115
+ // 2. Plain ':host'. The lookahead turns down longer names (':host-context') and '(',
116
+ // which only follows ':host' when alternative 1 already gave up on it, and accepts
117
+ // the end of the text node, where an expression may have split a dynamic style.
118
+ let newText = oldText.replace(
119
+ /:host(?:\(((?:[^()]|\([^()]*\))*)\)|(?![-a-z0-9_(]))/gi,
120
+ `${tagName}${attribSelector}$1`);
101
121
  if (oldText !== newText)
102
122
  child.textContent = newText;
103
123
  }
@@ -118,17 +138,15 @@ let Util = {
118
138
  * 'UIForm' => 'ui-form'
119
139
  * 'A100' => 'a-100' */
120
140
  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();
141
+ // One pass finds all three dash positions. Each alternative matches only the character
142
+ // *before* the boundary and uses a lookahead for what follows, so the following character
143
+ // is never consumed and can still start the next boundary. That's what lets the three
144
+ // rules interleave in a single scan the way three sequential replaces used to:
145
+ // 1. a lowercase letter or digit before a capital ('ProperName').
146
+ // 2. a capital before a capital+lowercase pair, i.e. the last capital of a run ('HTMLElement').
147
+ // 3. a letter before a digit ('A100').
148
+ // '$&-' appends the dash after the matched character, then everything folds to lowercase.
149
+ return str.replace(/[a-z0-9](?=[A-Z])|[A-Z](?=[A-Z][a-z])|[a-zA-Z](?=\d)/g, '$&-').toLowerCase();
132
150
  },
133
151
 
134
152
  /**
@@ -144,13 +162,24 @@ let Util = {
144
162
  return str.replace(/-([a-z])/g, g => g[1].toUpperCase());
145
163
  },
146
164
 
165
+ /**
166
+ * Register Class as a custom element, unless it's registered already.
167
+ * @param Class {typeof HTMLElement}
168
+ * @param tagName {?string} Name to register under. Defaults to the dashed form of the class name.
169
+ * @return {string} The tag name Class is registered under, whether we just registered it or it
170
+ * was already in the registry under some other name. Callers that emit markup for the class
171
+ * use this instead of re-deriving the name, which guesses wrong for any class registered
172
+ * under a name that isn't camelToDashes(Class.name). */
147
173
  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
- }
174
+ let defined = customElements[getName](Class);
175
+ if (defined) // Previously defined.
176
+ return defined;
177
+
178
+ tagName = tagName || Util.camelToDashes(Class.name)
179
+ if (!tagName.includes('-')) // Browsers require that web components always have a dash in the name.
180
+ tagName += '-element';
181
+ customElements[define](tagName, Class)
182
+ return tagName;
154
183
  },
155
184
 
156
185
  /**
@@ -177,8 +206,15 @@ let Util = {
177
206
  return node.value; // String
178
207
  },
179
208
 
180
- isEvent(attrName) {
181
- return attrName.startsWith('on') && attrName in Globals.div;
209
+ /**
210
+ * True for an attribute name that binds an event: `onclick`, or `native:onclick` for a
211
+ * handler that is registered with addEventListener when the template renders instead of
212
+ * being delegated. Only names an element really exposes as on* handlers count, so an
213
+ * attribute like `online` is never mistaken for one. */
214
+ isEvent(attribName) {
215
+ if (attribName.startsWith(nativeEventPrefix))
216
+ attribName = attribName.slice(nativeEventPrefix.length);
217
+ return attribName.startsWith('on') && attribName in Globals.div;
182
218
  },
183
219
 
184
220
  /**
@@ -215,16 +251,13 @@ let Util = {
215
251
  * @returns {Object} */
216
252
  splitAttribs(str) {
217
253
  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
- }
254
+
255
+ // One scan collects every name and its value. The value is optional so a boolean attribute
256
+ // written on its own ('disabled') still lands in the result with an empty value, and the three
257
+ // value alternatives capture *inside* the quotes so no separate quote-trimming pass is needed.
258
+ // Whatever doesn't look like an attribute name is skipped rather than becoming a bogus key.
259
+ (str + '').replace(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g,
260
+ (_, name, dq, sq, bare) => result[name] = dq ?? sq ?? bare ?? '');
228
261
 
229
262
  return result;
230
263
  },
@@ -262,19 +295,14 @@ let Util = {
262
295
  * @param nodes {Node[]|NodeList}
263
296
  * @returns {Node[]} */
264
297
  trimEmptyNodes(nodes) {
265
- const shouldTrimNode = node =>
266
- node.nodeType !== Node.ELEMENT_NODE &&
267
- (node.nodeType !== Node.TEXT_NODE || node.textContent.trim() === '');
298
+ // nodeType 1 is an element and 3 is a text node; the literals are what Node.ELEMENT_NODE
299
+ // and Node.TEXT_NODE are defined as, and they cost a fraction of the bytes.
300
+ let isEmpty = node => node.nodeType !== 1 && (node.nodeType !== 3 || !node.textContent.trim());
268
301
 
269
- // Convert nodeList to an array for easier manipulation
270
- const result = [...nodes]
271
-
272
- // Trim from the start
273
- while (result.length > 0 && shouldTrimNode(result[0]))
302
+ let result = [...nodes]; // A NodeList can't shift() or pop().
303
+ while (result.length && isEmpty(result[0]))
274
304
  result.shift();
275
-
276
- // Trim from the end
277
- while (result.length > 0 && shouldTrimNode(result[result.length - 1]))
305
+ while (result.length && isEmpty(result[result.length - 1]))
278
306
  result.pop();
279
307
 
280
308
  return result;
@@ -292,7 +320,7 @@ export default Util;
292
320
 
293
321
 
294
322
  // For debugging only
295
- //#IFDEV
323
+ //#IFDEBUG
296
324
  export function setIndent(items, level=1) {
297
325
  if (typeof items === 'string')
298
326
  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