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/h.js CHANGED
@@ -3,6 +3,8 @@ import Globals from "./Globals.js";
3
3
  import toEl from "./toEl.js";
4
4
  import Util from "./Util.js";
5
5
  import {jsxToTemplate, Fragment} from "./jsx.js";
6
+ import MappedList from "./MappedList.js";
7
+ import Selector from "./Selector.js";
6
8
 
7
9
  /**
8
10
  * Convert strings to HTMLNodes.
@@ -56,7 +58,11 @@ const renderTemplateKey = Symbol('solariteRender');
56
58
  // Using `arguments` alongside rest params would force the engine to materialize both per call.
57
59
  const noArg = Symbol();
58
60
 
59
- export default function h(htmlStrings=noArg, ...exprs) {
61
+ // The /** @type {*} */ cast on the default keeps TypeScript from inferring the parameter as
62
+ // `symbol` from noArg: TS can't parse the closure-style @param type above (function() without
63
+ // a return type under noImplicitAny), falls back to the default's type, and then flags every
64
+ // h(this) / h`` call in the codebase as an error. JetBrains reads the @param fine either way.
65
+ export default function h(htmlStrings=/** @type {*} */(noArg), ...exprs) {
60
66
 
61
67
  // 1. Tagged template: h`<div>...</div>`
62
68
  if (Array.isArray(htmlStrings)) {
@@ -109,11 +115,14 @@ export default function h(htmlStrings=noArg, ...exprs) {
109
115
  let parent = htmlStrings, options = exprs[0];
110
116
 
111
117
  // The closure is cached on the element so repeated renders don't recreate it.
112
- if (options === undefined) {
113
- let cached = parent[renderTemplateKey];
114
- if (cached)
115
- return cached;
116
- }
118
+ // Options are cached with it: they only take effect when the element's
119
+ // RootNodeGroup is first created, so a later render passing different ones is
120
+ // ignored either way, and caching regardless of them saves an allocation on every
121
+ // render of a component that passes an options object — which is how render() is
122
+ // usually written.
123
+ let cached = parent[renderTemplateKey];
124
+ if (cached)
125
+ return cached;
117
126
 
118
127
  // Return a tagged template function that applies the tagged template to parent.
119
128
  let renderTemplate = (htmlStrings, ...exprs) => {
@@ -125,8 +134,7 @@ export default function h(htmlStrings=noArg, ...exprs) {
125
134
  let template = new Template(htmlStrings, exprs);
126
135
  return template.render(parent, options);
127
136
  }
128
- if (options === undefined)
129
- parent[renderTemplateKey] = renderTemplate;
137
+ parent[renderTemplateKey] = renderTemplate;
130
138
  return renderTemplate;
131
139
  }
132
140
  }
@@ -143,11 +151,11 @@ export default function h(htmlStrings=noArg, ...exprs) {
143
151
  // Intercepts the main h(this)`...` function call inside render().
144
152
  // TODO: This path doesn't handle embeds like data-id="..."
145
153
  else if (typeof htmlStrings === 'object' && Globals.objToEl.has(htmlStrings)) {
154
+ // The only thing that ever puts an object into objToEl is toEl(), and it rejects anything
155
+ // that isn't a plain object before it does so, so an object that reaches here has already
156
+ // been checked and re-checking it can never report anything.
146
157
  let obj = htmlStrings;
147
158
 
148
- if (obj.constructor.name !== 'Object')
149
- throw new Error(`Solarate Web Component class ${obj.constructor?.name} must extend HTMLElement.`);
150
-
151
159
  // Jsx with h(this, <jsx>)
152
160
  if (exprs[0] instanceof Template) {
153
161
  let template = exprs[0];
@@ -171,14 +179,6 @@ export default function h(htmlStrings=noArg, ...exprs) {
171
179
  throw new Error('h() does not support argument of type: ' + (htmlStrings ? typeof htmlStrings : htmlStrings))
172
180
  }
173
181
 
174
- // h.map caches each item's Template keyed by the item's identity, so a re-render returns
175
- // the SAME Template instance for any item whose reference is unchanged. The reconciler's
176
- // `ng.template === item` fast path (PathToNodes.applyKeyed/applyDiff) then skips rebuilding
177
- // and comparing that row. A WeakMap is used instead of a symbol property so the idiomatic
178
- // immutable update `{...item, x}` yields a fresh object that ISN'T in the cache and re-renders;
179
- // a symbol property would be copied by spread and silently reuse the stale Template.
180
- const mapCache = new WeakMap();
181
-
182
182
  /**
183
183
  * Render a list, reusing each item's DOM for as long as the item is the SAME object.
184
184
  *
@@ -195,26 +195,53 @@ const mapCache = new WeakMap();
195
195
  *
196
196
  * ${h.map(this.rows, row => h`<tr key=${row.id}>${row.label}</tr>`)}
197
197
  *
198
+ * What comes back is a MappedList, not an array: it carries the items and the callback so
199
+ * the reconciler can match a row to its item by identity and call the callback only for the
200
+ * rows it can't match. Put it straight into a template expression, as above; nested inside
201
+ * an array, or returned from a function, it expands to Templates just the same.
202
+ *
198
203
  * @param items {Array} The list to render.
199
204
  * @param fn {function(item:*):Template} Builds an item's Template; called only for new items.
200
- * @return {Template[]} */
201
- h.map = (items, fn) => {
202
- let result = new Array(items.length);
203
- for (let i=0; i<items.length; i++) {
204
- let item = items[i];
205
- if (item !== null && typeof item === 'object') {
206
- let template = mapCache.get(item);
207
- if (template === undefined) {
208
- template = fn(item);
209
- mapCache.set(item, template);
210
- }
211
- result[i] = template;
212
- }
213
- else
214
- result[i] = fn(item);
215
- }
216
- return result;
217
- }
205
+ * @return {MappedList} */
206
+ h.map = (items, fn) => new MappedList(items, fn);
218
207
 
219
208
  h.immutableMap = h.map;
220
209
 
210
+ /**
211
+ * Create a selection that updates only the rows it affects.
212
+ *
213
+ * A highlight that moves from one row of a thousand to another changes two attributes.
214
+ * Expressing it as ordinary state means calling render() and letting the reconciler walk the
215
+ * list to rediscover that. A selector writes those two attributes directly instead:
216
+ *
217
+ * class Table extends Solarite {
218
+ * selected = h.selector();
219
+ *
220
+ * pick(row) {
221
+ * this.selected.set(row.id); // no render() call
222
+ * }
223
+ *
224
+ * render() {
225
+ * h(this)`<tbody>${h.map(this.rows, row =>
226
+ * h`<tr key=${row.id} class=${this.selected.when(row.id, 'danger')}
227
+ * onclick=${[this.pick, row]}>${row.label}</tr>`)}</tbody>`;
228
+ * }
229
+ * }
230
+ *
231
+ * when() must be a whole attribute value, not part of one and not element content, since it
232
+ * owns that attribute for as long as the row exists. An off value of '' leaves no attribute
233
+ * behind at all. Selection state lives on the selector, so it survives re-renders, and
234
+ * set() is safe to call whether or not the rows are currently rendered.
235
+ *
236
+ * Two rules follow from how set() finds a row, and both throw a clear error rather than
237
+ * misbehaving quietly. **The rows must be keyed** — set() locates a row by looking its key
238
+ * up in the list, so the row template needs a key=${...}. And **the attribute must sit on
239
+ * the row's own root element**, the same one that carries the key, because that is the
240
+ * element set() writes. Drawing a row costs nothing either way: when() hands back one of
241
+ * two shared objects rather than allocating anything per row, so a selector is free to
242
+ * render over a list of any size and only a change of selection does any work.
243
+ *
244
+ * @param key {*} The initially selected key, or null for none.
245
+ * @return {Selector} */
246
+ h.selector = (key = null) => new Selector(key);
247
+
package/src/jsx.js CHANGED
@@ -115,13 +115,10 @@ export function jsxToTemplate(tag, props, children=[], key=undefined) {
115
115
 
116
116
  // 2a. Custom element class => emit <tag-name ...props>children</tag-name>; PathToComponent
117
117
  // instantiates it exactly like a tagged-template component.
118
- if (tag.prototype instanceof HTMLElement) {
119
- Util.defineClass(tag);
120
- let tagName = customElements.getName ? customElements.getName(tag) : Util.camelToDashes(tag.name);
121
- if (tagName && !tagName.includes('-'))
122
- tagName += '-element';
123
- return buildIntrinsic(tagName, props, children, key);
124
- }
118
+ // defineClass() hands back the name it registered, or the name the class was already
119
+ // registered under, so we never have to guess it a second time.
120
+ if (tag.prototype instanceof HTMLElement)
121
+ return buildIntrinsic(Util.defineClass(tag), props, children, key);
125
122
 
126
123
  // 2b. Plain function component: call it with props (+ children) and expect a Template back.
127
124
  let p = {};
package/src/toEl.js CHANGED
@@ -41,7 +41,7 @@ export default function toEl(arg) {
41
41
  let obj = arg;
42
42
 
43
43
  if (obj.constructor.name !== 'Object')
44
- throw new Error(`Solarate Web Component class ${obj.constructor?.name} must extend HTMLElement.`);
44
+ throw new Error(`Solarite web component class ${obj.constructor?.name} must extend HTMLElement.`);
45
45
 
46
46
  // Normal path
47
47
  if (!Globals.objToEl.has(obj)) {
package/src/HtmlParser.js DELETED
@@ -1,91 +0,0 @@
1
-
2
- export default class HtmlParser {
3
- constructor() {
4
- this.defaultState = {
5
- context: HtmlParser.Text, // possible values: 'TEXT', 'TAG', 'ATTRIBUTE'
6
- quote: null, // possible values: null, '"', "'"
7
- buffer: '',
8
- lastChar: null
9
- };
10
- this.state = {...this.defaultState};
11
- }
12
-
13
- reset() {
14
- this.state = {...this.defaultState};
15
- return this.state.context;
16
- }
17
-
18
- /**
19
- * Parse the next chunk of html, starting with the same context we left off with from the previous chunk.
20
- * @param html {string}
21
- * @param onContextChange {?function(html:string, index:int, prevContext:string, nextContext:string)}
22
- * Called every time the context changes, and again at the last context.
23
- * @return {('Attribute','Text','Tag')} The context at the end of html. */
24
- parse(html, onContextChange=null) {
25
- if (html === null)
26
- return this.reset();
27
-
28
- for (let i = 0; i < html.length; i++) {
29
- const char = html[i];
30
- switch (this.state.context) {
31
- case HtmlParser.Text:
32
- if (char === '<' && html[i + 1].match(/[/a-z!]/i)) { // Start of a tag or comment.
33
- onContextChange?.(html, i, this.state.context, HtmlParser.Tag);
34
- this.state.context = HtmlParser.Tag;
35
- this.state.buffer = '';
36
- }
37
- break;
38
- case HtmlParser.Tag:
39
- if (char === '>') {
40
- onContextChange?.(html, i+1, this.state.context, HtmlParser.Text);
41
- this.state.context = HtmlParser.Text;
42
- this.state.quote = null;
43
- this.state.buffer = '';
44
- }
45
- else if (char === ' ' && !this.state.buffer) {
46
- // No attribute name is present. Skipping the space.
47
- continue;
48
- }
49
- else if (char === ' ' || char === '/' || char === '?') {
50
- this.state.buffer = ''; // Reset the buffer when a delimiter or potential self-closing sign is found.
51
- }
52
- else if (char === '"' || char === "'" || char === '=') {
53
- onContextChange?.(html, i, this.state.context, HtmlParser.Attribute);
54
- this.state.context = HtmlParser.Attribute;
55
- this.state.quote = char === '=' ? null : char;
56
- this.state.buffer = '';
57
- }
58
- else
59
- this.state.buffer += char;
60
- break;
61
- case HtmlParser.Attribute:
62
- // Start an attribute quote.
63
- if (!this.state.quote && !this.state.buffer.length && (char === '"' || char === "'")) {
64
- this.state.quote = char;
65
- }
66
- else if (char === this.state.quote || (!this.state.quote && this.state.buffer.length)) {
67
- onContextChange?.(html, i, this.state.context, HtmlParser.Tag);
68
- this.state.context = HtmlParser.Tag;
69
- this.state.quote = null;
70
- this.state.buffer = '';
71
- }
72
- else if (!this.state.quote && char === '>') {
73
- onContextChange?.(html, i+1, this.state.context, HtmlParser.Text);
74
- this.state.context = HtmlParser.Text;
75
- this.state.quote = null;
76
- this.state.buffer = '';
77
- }
78
- else if (char !== ' ')
79
- this.state.buffer += char;
80
-
81
- break;
82
- }
83
- }
84
- onContextChange?.(html, html.length, this.state.context, null);
85
- return this.state.context;
86
- }
87
- }
88
-
89
- HtmlParser.Attribute = 'Attribute';
90
- HtmlParser.Text = 'Text';
91
- HtmlParser.Tag = 'Tag';
@@ -1,8 +0,0 @@
1
- import Path from "./Path.js";
2
-
3
- // This Path renders nothing.
4
- export default class PathToComment extends Path {
5
- constructor(nodeBefore, nodeMarker) {
6
- super(nodeBefore, nodeMarker);
7
- }
8
- }