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.
@@ -1,12 +1,13 @@
1
1
  import NodeGroup from './NodeGroup.js';
2
2
  import Globals from './Globals.js';
3
+ import Util from './Util.js';
3
4
 
4
5
  /**
5
6
  * Has these properties not present on NodeGroup, assigned by instantiate():
6
7
  * They're not declared as fields because subclass field initializers run after the
7
8
  * super constructor and would overwrite the assigned values.
8
- * @property {HTMLElement} root - Root node at the top of the hierarchy.
9
- * @property {?object} options - RenderOptions */
9
+ * @property {HTMLElement} rootEl - Root node at the top of the hierarchy.
10
+ * @property {?object} renderOptions - RenderOptions */
10
11
  export default class RootNodeGroup extends NodeGroup {
11
12
 
12
13
  /**
@@ -15,19 +16,19 @@ export default class RootNodeGroup extends NodeGroup {
15
16
  * Called by the NodeGroup constructor. */
16
17
  instantiate(shell, shellFragment, el, options) {
17
18
  let startingPathDepth = 0;
18
- this.options = options;
19
+ this.renderOptions = options;
19
20
  if (shellFragment instanceof Text) {
20
21
  if (!el)
21
- throw new Error('Cannot create a standalone text node');
22
+ throw new Error('Text node needs an element.');
22
23
 
23
- this.root = el;
24
+ this.rootEl = el;
24
25
  if (shellFragment.nodeValue.length)
25
- this.root.append(shellFragment);
26
+ this.rootEl.append(shellFragment);
26
27
  }
27
28
 
28
29
  else {
29
30
  if (el) {
30
- this.root = el;
31
+ this.rootEl = el;
31
32
 
32
33
  // Save slot
33
34
  // 1. Globals.currentSlotChildren is set if this is called via PathToComponent.applyComponent() calls render()
@@ -39,13 +40,13 @@ export default class RootNodeGroup extends NodeGroup {
39
40
  }
40
41
 
41
42
  // If el should replace the root node of the fragment.
42
- if (isReplaceEl(shellFragment, this.root.tagName)) {
43
- this.root.append(...shellFragment.children[0].childNodes);
43
+ if (isReplaceEl(shellFragment, this.rootEl.tagName)) {
44
+ this.rootEl.append(...shellFragment.children[0].childNodes);
44
45
 
45
46
  // Copy attributes
46
47
  for (let attrib of shellFragment.children[0].attributes)
47
- if (!this.root.hasAttribute(attrib.name))
48
- this.root.setAttribute(attrib.name, attrib.value);
48
+ if (!this.rootEl.hasAttribute(attrib.name))
49
+ this.rootEl.setAttribute(attrib.name, attrib.value);
49
50
 
50
51
  // Go one level deeper into all of shell's paths.
51
52
  startingPathDepth = 1;
@@ -54,7 +55,7 @@ export default class RootNodeGroup extends NodeGroup {
54
55
  else {
55
56
  let isEmpty = shellFragment.childNodes.length === 1 && shellFragment.childNodes[0].nodeType === 3 && shellFragment.childNodes[0].textContent === '';
56
57
  if (!isEmpty)
57
- this.root.append(...shellFragment.childNodes);
58
+ this.rootEl.append(...shellFragment.childNodes);
58
59
  }
59
60
 
60
61
 
@@ -80,34 +81,26 @@ export default class RootNodeGroup extends NodeGroup {
80
81
 
81
82
  // Instantiate as a standalone element.
82
83
  else {
83
- let onlyChild = getSingleEl(shellFragment);
84
- this.root = onlyChild || shellFragment; // We return the whole fragment when calling h() with a collection of nodes.
84
+ // Trimming the whitespace and comment nodes off both ends leaves a list of exactly
85
+ // one node only when the fragment has exactly one node worth keeping, which is the
86
+ // question being asked here.
87
+ let relevantNodes = Util.trimEmptyNodes(shellFragment.childNodes);
88
+ let onlyChild = relevantNodes.length === 1 ? relevantNodes[0] : null;
89
+ this.rootEl = onlyChild || shellFragment; // We return the whole fragment when calling h() with a collection of nodes.
85
90
  if (onlyChild)
86
91
  startingPathDepth = 1;
87
92
  }
88
93
 
89
- this.setPathsFromFragment(this.root, shell, startingPathDepth);
90
- this.activateEmbeds(this.root, shell, startingPathDepth);
94
+ this.setPathsFromFragment(this.rootEl, shell, startingPathDepth);
95
+ this.activateEmbeds(this.rootEl, shell, startingPathDepth);
91
96
  }
92
- this.startNode = this.endNode = this.root;
97
+ this.startNode = this.endNode = this.rootEl;
93
98
 
94
- Globals.rootNodeGroups.set(this.root, this);
99
+ Globals.rootNodeGroups.set(this.rootEl, this);
95
100
  }
96
101
  }
97
102
 
98
103
 
99
- function getSingleEl(fragment) {
100
- let nonempty = [];
101
- for (let n of fragment.childNodes) {
102
- if (n.nodeType === 1 || n.nodeType === 3 && n.textContent.trim().length) {
103
- if (nonempty.length)
104
- return null;
105
- nonempty.push(n);
106
- }
107
- }
108
- return nonempty[0];
109
- }
110
-
111
104
  /**
112
105
  * Does the fragment have one child that's an element matching the tagname of el?
113
106
  * @param fragment {DocumentFragment}
@@ -0,0 +1,207 @@
1
+ /**
2
+ * A key-scoped selection that updates only the rows it actually affects.
3
+ *
4
+ * Rendering a list normally means calling render() and letting the reconciler decide what
5
+ * changed. That is the right default, but it is a poor fit for a selection: moving a
6
+ * highlight from one row of a thousand to another changes two attributes, and asking the
7
+ * reconciler about it means walking the whole list to discover that fact.
8
+ *
9
+ * A Selector short-circuits that. when() hands each row one of exactly two objects — the
10
+ * selected one or the unselected one — and set() reaches the two rows that change through
11
+ * the list they were rendered into, writing their attributes directly with no render() call.
12
+ *
13
+ * This is the same primitive as Solid's createSelector, adapted to a library that has no
14
+ * signals: the list, not a subscription, is what carries the binding.
15
+ *
16
+ * Because set() locates a row by its key, **the rows must be keyed** — the row template needs
17
+ * a key=${...} attribute. set() throws on an unkeyed list rather than silently doing nothing.
18
+ */
19
+
20
+ /**
21
+ * The value an attribute is bound to. There are only ever **two** of these per Selector,
22
+ * both built in its constructor: one standing for "this row is the selected one" and one for
23
+ * "this row is not". when() returns whichever of the two the row's key calls for.
24
+ *
25
+ * Two singletons rather than one object per key is what makes a selector free to create. A
26
+ * row of a freshly-drawn list with nothing selected gets the unselected singleton, whose
27
+ * value is the off value, so there is no allocation, no map entry and no DOM call — only the
28
+ * two stores that record where the list lives. It also sharpens the re-render skip: a row's
29
+ * expression changes identity exactly when its selectedness changes, so
30
+ * NodeGroup.rewriteStamp() rewrites the rows that gained or lost the selection and no others.
31
+ */
32
+ export class SelectorRef {
33
+
34
+ /** @type {Selector} */
35
+ selector;
36
+
37
+ /** @type {boolean} True on the singleton that stands for the selected row. */
38
+ selected;
39
+
40
+ constructor(selector, selected) {
41
+ this.selector = selector;
42
+ this.selected = selected;
43
+ }
44
+
45
+ /** @return {*} The value this ref currently stands for. */
46
+ value() {
47
+ let s = this.selector;
48
+ return this.selected ? s.onValue : s.offValue;
49
+ }
50
+
51
+ /**
52
+ * Write this ref's value to an element's attribute, and tell the selector where the list
53
+ * is so that a later set() can find any row in it.
54
+ *
55
+ * Called by PathToAttribValue when the ref appears as an attribute expression. It runs
56
+ * once per row per render, so it is deliberately nothing but two stores and a write that
57
+ * the common case skips.
58
+ *
59
+ * @param node {Node} The element carrying the attribute.
60
+ * @param attribName {string}
61
+ * @param parentNg {NodeGroup} The row this attribute belongs to. */
62
+ bind(node, attribName, parentNg) {
63
+ // set() writes through the row's own root element, so an attribute anywhere deeper
64
+ // would be found at bind time and then written somewhere else at set() time. Catching
65
+ // it here turns a silently misplaced attribute into a clear message. It SHIPS: it is not
66
+ // in a debug-strip block, and it must not be, because the failure it catches is silent.
67
+ if (parentNg.startNode !== node)
68
+ throw new Error(`Solarite: a selector must be on the row's root element.`);
69
+
70
+ let s = this.selector;
71
+ s.attribName = attribName;
72
+ s.path = parentNg.parentPath;
73
+
74
+ let v = this.selected ? s.onValue : s.offValue;
75
+
76
+ // Matches PathToAttribValue.applySingle: an empty or falsy value leaves no attribute
77
+ // behind, so a selector never adds markup a hand-written implementation wouldn't have.
78
+ if (v === '' || v === false || v === null || v === undefined) {
79
+ // A just-cloned row provably carries no attribute of this name yet, so the
80
+ // removeAttribute — a DOM call for every row of the list — can be skipped.
81
+ if (parentNg.firstApply !== true)
82
+ node.removeAttribute(attribName);
83
+ }
84
+ else
85
+ node.setAttribute(attribName, v);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Created by h.selector(). Holds one selected key.
91
+ *
92
+ * Only attribute expressions can bind a selector; using one as element content throws,
93
+ * because writing text through this path would need bookkeeping the two-node fast case
94
+ * doesn't want.
95
+ *
96
+ * The selector keeps **no per-row state at all** — no map of keys, nothing to sweep, and
97
+ * nothing that could pin a removed row's element in memory. All it remembers is which
98
+ * attribute it drives and which list it was rendered into.
99
+ */
100
+ export default class Selector {
101
+
102
+ /** @type {*} The selected key, or null. */
103
+ #key = null;
104
+
105
+ /** @type {SelectorRef} Returned by when() for the row whose key is selected. */
106
+ #on = new SelectorRef(this, true);
107
+
108
+ /** @type {SelectorRef} Returned by when() for every other row. */
109
+ #off = new SelectorRef(this, false);
110
+
111
+ /** @type {*} Value the bound attribute takes for the selected key. Held here rather than
112
+ * on each ref, so the two refs stay interchangeable between call sites. */
113
+ onValue;
114
+
115
+ /** @type {*} Value it takes for every other key. */
116
+ offValue = '';
117
+
118
+ /** @type {?string} The attribute this selector drives, learned when a row binds. */
119
+ attribName = null;
120
+
121
+ /** @type {?PathToNodes} The list this selector's rows were rendered into, learned when a
122
+ * row binds. set() asks it for the NodeGroup holding a given key. */
123
+ path = null;
124
+
125
+ /** @param key {*} The initially selected key. */
126
+ constructor(key = null) {
127
+ this.#key = key;
128
+ }
129
+
130
+ /** @return {*} The selected key. */
131
+ get key() {
132
+ return this.#key;
133
+ }
134
+
135
+ /**
136
+ * Bind an attribute to whether key is the selected one.
137
+ *
138
+ * h`<tr key=${row.id} class=${sel.when(row.id, 'danger')}>`
139
+ *
140
+ * @param key {*} This row's key.
141
+ * @param on {*} Value the attribute takes when key is selected.
142
+ * @param off {*} Value it takes otherwise. '' removes the attribute.
143
+ * @return {SelectorRef} */
144
+ when(key, on, off = '') {
145
+ this.onValue = on;
146
+ this.offValue = off;
147
+ return key === this.#key ? this.#on : this.#off;
148
+ }
149
+
150
+ /**
151
+ * Move the selection. Writes at most two attributes — the row losing the selection and
152
+ * the row gaining it — and touches nothing else. There is no render() call.
153
+ * @param key {*} The newly selected key, or null for none. */
154
+ set(key) {
155
+ let old = this.#key;
156
+ if (old === key)
157
+ return;
158
+ this.#key = key;
159
+
160
+ // Nothing has rendered a row yet, so there is no list to write into. The new key
161
+ // still takes effect: rows drawn later come up already carrying the attribute.
162
+ if (this.path === null)
163
+ return;
164
+
165
+ this.#write(old, this.offValue);
166
+ this.#write(key, this.onValue);
167
+ }
168
+
169
+ /**
170
+ * Find the row holding key and give its root element the value v.
171
+ * @param key {*}
172
+ * @param v {*} */
173
+ #write(key, v) {
174
+ if (key === null || key === undefined)
175
+ return;
176
+
177
+ let ngs = this.path.nodeGroups;
178
+ if (ngs === null || ngs.length === 0)
179
+ return;
180
+
181
+ if (ngs[0].key === undefined)
182
+ throw new Error('Solarite: a selector must be on a keyed list, as key=${...}.');
183
+
184
+ // A linear scan over the rows. The list is walked only when the selection actually
185
+ // moves — twice per user click, not once per row per render — so a thousand pointer
186
+ // comparisons here cost far less than the per-row index that would avoid them.
187
+ let ng = null;
188
+ for (let i = 0; i < ngs.length; i++)
189
+ if (ngs[i].key === key) {
190
+ ng = ngs[i];
191
+ break;
192
+ }
193
+ if (ng === null)
194
+ return;
195
+
196
+ // The selector owns an attribute on the row's own root element, which for a
197
+ // single-root row template is exactly the NodeGroup's startNode.
198
+ let node = ng.startNode;
199
+ if (node === null || node.nodeType !== 1)
200
+ return;
201
+
202
+ if (v === '' || v === false || v === null || v === undefined)
203
+ node.removeAttribute(this.attribName);
204
+ else
205
+ node.setAttribute(this.attribName, v);
206
+ }
207
+ }