galath 1.0.2
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/AGENTS.md +1 -0
- package/README.md +206 -0
- package/TODO.md +140 -0
- package/index.html +188 -0
- package/logo.jpg +0 -0
- package/logo.svg +96 -0
- package/package.json +32 -0
- package/packages/galath/package.json +28 -0
- package/packages/galath/src/behavior.js +193 -0
- package/packages/galath/src/binding.js +247 -0
- package/packages/galath/src/boot.js +52 -0
- package/packages/galath/src/command.js +117 -0
- package/packages/galath/src/component.js +505 -0
- package/packages/galath/src/controller.js +181 -0
- package/packages/galath/src/core.js +190 -0
- package/packages/galath/src/imports.js +132 -0
- package/packages/galath/src/index.js +38 -0
- package/packages/galath/src/instance-model.js +343 -0
- package/packages/galath/src/morph.js +237 -0
- package/packages/galath/src/rendering.js +556 -0
- package/packages/galath/src/signals.js +215 -0
- package/packages/galath/src/templates.js +24 -0
- package/packages/galath/src/xml-events.js +53 -0
- package/packages/galath-css/css/bootstrap-icons.min.css +5 -0
- package/packages/galath-css/css/bootstrap.min.css +6 -0
- package/packages/galath-css/css/fonts/bootstrap-icons.json +2077 -0
- package/packages/galath-css/css/fonts/bootstrap-icons.woff +0 -0
- package/packages/galath-css/css/fonts/bootstrap-icons.woff2 +0 -0
- package/packages/galath-css/js/bootstrap.bundle.min.js +7 -0
- package/packages/galath-css/package.json +13 -0
- package/playground/app.xml +214 -0
- package/playground/chapters/01-welcome.xml +94 -0
- package/playground/chapters/02-signals.xml +166 -0
- package/playground/chapters/03-instance.xml +130 -0
- package/playground/chapters/04-bindings.xml +156 -0
- package/playground/chapters/05-lists.xml +138 -0
- package/playground/chapters/06-commands.xml +144 -0
- package/playground/chapters/07-controller.xml +115 -0
- package/playground/chapters/08-events.xml +126 -0
- package/playground/chapters/09-behaviors.xml +210 -0
- package/playground/chapters/10-components.xml +152 -0
- package/playground/chapters/11-imports.xml +108 -0
- package/playground/chapters/12-expressions.xml +161 -0
- package/playground/chapters/13-paths.xml +197 -0
- package/playground/components/chapter-shell.xml +29 -0
- package/playground/components/highlighter.js +111 -0
- package/playground/components/run-snippet.js +120 -0
- package/public/basic/bootstrap-icons.min.css +5 -0
- package/public/basic/bootstrap.bundle.min.js +7 -0
- package/public/basic/bootstrap.min.css +6 -0
- package/public/basic/fonts/bootstrap-icons.json +2077 -0
- package/public/basic/fonts/bootstrap-icons.woff +0 -0
- package/public/basic/fonts/bootstrap-icons.woff2 +0 -0
- package/public/basic/theme.css +209 -0
- package/seed.html +321 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// instance-model.js
|
|
3
|
+
//
|
|
4
|
+
// XForms-style data model. State is an XML *instance tree* of `XNode`s.
|
|
5
|
+
// Every attribute and text node is itself a `Signal`, so views can bind
|
|
6
|
+
// directly to paths and react to mutations.
|
|
7
|
+
//
|
|
8
|
+
// Public surface:
|
|
9
|
+
//
|
|
10
|
+
// XNode - one element node with its own attribute signals.
|
|
11
|
+
// XTree - the tree wrapper; emits routed events on every mutation.
|
|
12
|
+
// coerce - string -> primitive coercion shared with bindings.
|
|
13
|
+
// parseDataElement - parse an XML element into an XNode, recursively.
|
|
14
|
+
//
|
|
15
|
+
// Path syntax (a strict subset of XPath - intentional):
|
|
16
|
+
//
|
|
17
|
+
// /todos/todo child step
|
|
18
|
+
// /todos/todo[@done=true] attribute predicate (equality)
|
|
19
|
+
// /todos/todo[@text=hello] unquoted predicate value
|
|
20
|
+
// /todos/todo[2] 1-based index predicate
|
|
21
|
+
// $todo local variable bound by repeat/items
|
|
22
|
+
// $todo/@text attribute on a local variable
|
|
23
|
+
//
|
|
24
|
+
// We only implement what views need; full XPath is deliberately out of scope.
|
|
25
|
+
// If you find yourself wanting predicates with `or`, multi-step descendants,
|
|
26
|
+
// or function calls, please reach for a `<computed>` instead - that's the
|
|
27
|
+
// pressure valve.
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
import { assert } from './core.js';
|
|
31
|
+
|
|
32
|
+
export function instanceModelFeature(language) {
|
|
33
|
+
const { Signal, Disposable } = language;
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// XNode - reactive XML element node
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
class XNode {
|
|
39
|
+
constructor(name, attrs = {}, text = '') {
|
|
40
|
+
this.name = name;
|
|
41
|
+
this.parent = null;
|
|
42
|
+
this.children = [];
|
|
43
|
+
this.tree = null; // back-pointer to the owning XTree, set on insert.
|
|
44
|
+
this.text = new Signal(text); // own text content (not children's).
|
|
45
|
+
this.attributes = new Map();
|
|
46
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
47
|
+
this.attributes.set(k, new Signal(coerce(v)));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the signal for an attribute, creating an empty one if it doesn't
|
|
53
|
+
* exist. Lazy creation matters: a binding can subscribe to `@foo` before
|
|
54
|
+
* any code has set it, and we want the first set to fire that
|
|
55
|
+
* subscriber.
|
|
56
|
+
*/
|
|
57
|
+
attr(name) {
|
|
58
|
+
if (!this.attributes.has(name)) this.attributes.set(name, new Signal(''));
|
|
59
|
+
return this.attributes.get(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get(name) {
|
|
63
|
+
return this.attr(name).value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
set(name, value) {
|
|
67
|
+
const sig = this.attr(name);
|
|
68
|
+
const oldValue = sig.value;
|
|
69
|
+
sig.value = coerce(value);
|
|
70
|
+
if (!Object.is(oldValue, sig.value)) {
|
|
71
|
+
this.tree?.notify({
|
|
72
|
+
type: 'xforms-value-changed',
|
|
73
|
+
node: this,
|
|
74
|
+
attribute: name,
|
|
75
|
+
oldValue,
|
|
76
|
+
value: sig.value,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Append a child. Pass `silent: true` during initial parse to skip events. */
|
|
82
|
+
append(child, { silent = false } = {}) {
|
|
83
|
+
child.parent = this;
|
|
84
|
+
this.children.push(child);
|
|
85
|
+
child.adoptTree(this.tree);
|
|
86
|
+
if (!silent) {
|
|
87
|
+
this.tree?.notify({ type: 'xforms-insert', node: child, parent: this });
|
|
88
|
+
}
|
|
89
|
+
return child;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
insertBefore(child, reference, { silent = false } = {}) {
|
|
93
|
+
child.parent = this;
|
|
94
|
+
child.adoptTree(this.tree);
|
|
95
|
+
const i = this.children.indexOf(reference);
|
|
96
|
+
if (i < 0) this.children.push(child);
|
|
97
|
+
else this.children.splice(i, 0, child);
|
|
98
|
+
if (!silent) {
|
|
99
|
+
this.tree?.notify({ type: 'xforms-insert', node: child, parent: this });
|
|
100
|
+
}
|
|
101
|
+
return child;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
remove() {
|
|
105
|
+
if (!this.parent) return;
|
|
106
|
+
const parent = this.parent;
|
|
107
|
+
parent.children = parent.children.filter(c => c !== this);
|
|
108
|
+
this.parent = null;
|
|
109
|
+
parent.tree?.notify({ type: 'xforms-delete', node: this, parent });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Recursively claim a tree pointer for this subtree (after splicing). */
|
|
113
|
+
adoptTree(tree) {
|
|
114
|
+
this.tree = tree;
|
|
115
|
+
for (const c of this.children) c.adoptTree(tree);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Slash-separated path from the root, e.g. "/todos/todo". */
|
|
119
|
+
path() {
|
|
120
|
+
const parts = [];
|
|
121
|
+
let n = this;
|
|
122
|
+
while (n && n.parent) {
|
|
123
|
+
parts.unshift(n.name);
|
|
124
|
+
n = n.parent;
|
|
125
|
+
}
|
|
126
|
+
return '/' + parts.join('/');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// XTree - owner of the root XNode plus listener bus
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
class XTree {
|
|
134
|
+
constructor(root) {
|
|
135
|
+
this.root = root;
|
|
136
|
+
// Monotonic version counter. The renderer subscribes to this so any
|
|
137
|
+
// mutation in the tree triggers a re-render.
|
|
138
|
+
this.version = new Signal(0);
|
|
139
|
+
// Routed listeners by event type. Use '*' to listen to everything.
|
|
140
|
+
this.listeners = new Map();
|
|
141
|
+
root.adoptTree(this);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
on(type, fn) {
|
|
145
|
+
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
|
146
|
+
this.listeners.get(type).add(fn);
|
|
147
|
+
return new Disposable(() => this.listeners.get(type)?.delete(fn));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Dispatch an event to subscribers. Always increments `version` so the
|
|
152
|
+
* renderer wakes up. Event objects are augmented with a `path` so XML
|
|
153
|
+
* `<listener observer="/some/path">` can filter by prefix.
|
|
154
|
+
*/
|
|
155
|
+
notify(event) {
|
|
156
|
+
event.path = event.node?.path?.() || event.parent?.path?.() || '/';
|
|
157
|
+
this.version.value = this.version.value + 1;
|
|
158
|
+
for (const fn of [...(this.listeners.get(event.type) ?? [])]) fn(event);
|
|
159
|
+
for (const fn of [...(this.listeners.get('*') ?? [])]) fn(event);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Resolve a path to a list of XNodes. Returns [] when no match. */
|
|
163
|
+
select(path, context = {}) {
|
|
164
|
+
if (!path) return [];
|
|
165
|
+
path = String(path).trim();
|
|
166
|
+
// `/foo/@attr` and `/foo/text()` are *value* selectors; treated here as
|
|
167
|
+
// "node containing the value" so callers can post-process.
|
|
168
|
+
if (path.includes('/@')) path = path.slice(0, path.lastIndexOf('/@'));
|
|
169
|
+
if (path.endsWith('/text()')) path = path.slice(0, -7);
|
|
170
|
+
|
|
171
|
+
// Local variable form: $name or $name/...
|
|
172
|
+
if (path.startsWith('$')) {
|
|
173
|
+
const m = path.match(/^\$([A-Za-z_][\w-]*)(?:\/(.*))?$/);
|
|
174
|
+
if (!m) return [];
|
|
175
|
+
const base = context[`$${m[1]}`] ?? context[m[1]];
|
|
176
|
+
if (!base) return [];
|
|
177
|
+
const rest = m[2];
|
|
178
|
+
return rest ? selectFrom(base, splitPath(rest)) : [base];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Absolute form: /foo/bar
|
|
182
|
+
return selectFrom(this.root, splitPath(path.replace(/^\//, '')));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Resolve a path or `path/@attr` or `path/text()` to a value. */
|
|
186
|
+
valueOf(path, context = {}) {
|
|
187
|
+
const a = String(path).match(/^(.*)\/@([\w:-]+)$/);
|
|
188
|
+
if (a) return this.select(a[1], context)[0]?.get(a[2]) ?? '';
|
|
189
|
+
const t = String(path).match(/^(.*)\/text\(\)$/);
|
|
190
|
+
if (t) return this.select(t[1], context)[0]?.text.value ?? '';
|
|
191
|
+
const node = this.select(path, context)[0];
|
|
192
|
+
return node?.text.value ?? node ?? '';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Write a value to `path/@attr` or `path/text()`. */
|
|
196
|
+
setValue(path, value, context = {}) {
|
|
197
|
+
const a = String(path).match(/^(.*)\/@([\w:-]+)$/);
|
|
198
|
+
if (a) {
|
|
199
|
+
for (const node of this.select(a[1], context)) node.set(a[2], value);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const t = String(path).match(/^(.*)\/text\(\)$/);
|
|
203
|
+
if (t) {
|
|
204
|
+
for (const node of this.select(t[1], context)) {
|
|
205
|
+
const old = node.text.value;
|
|
206
|
+
node.text.value = coerce(value);
|
|
207
|
+
if (!Object.is(old, node.text.value)) {
|
|
208
|
+
this.notify({
|
|
209
|
+
type: 'xforms-value-changed',
|
|
210
|
+
node,
|
|
211
|
+
oldValue: old,
|
|
212
|
+
value: node.text.value,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Path internals
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
// Split a path into steps but tolerate predicate brackets that may
|
|
225
|
+
// contain slashes in the value. Today we don't allow slashes in values, so
|
|
226
|
+
// the simple split is fine; this helper keeps the call site readable.
|
|
227
|
+
function splitPath(pathBody) {
|
|
228
|
+
return pathBody.split('/').filter(Boolean);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Recursive walk: from `node`, take the step in `parts[0]` and recurse on
|
|
232
|
+
// the rest. `*` matches any element name.
|
|
233
|
+
function selectFrom(node, parts) {
|
|
234
|
+
if (parts.length === 0) return [node];
|
|
235
|
+
const [raw, ...tail] = parts;
|
|
236
|
+
const step = parseStep(raw);
|
|
237
|
+
if (!step) return [];
|
|
238
|
+
let matches = node.children.filter(c => step.name === '*' || c.name === step.name);
|
|
239
|
+
if (step.attr) {
|
|
240
|
+
matches = matches.filter(c => looseEqual(c.get(step.attr), step.value));
|
|
241
|
+
}
|
|
242
|
+
if (step.index != null) {
|
|
243
|
+
matches = matches[step.index - 1] ? [matches[step.index - 1]] : [];
|
|
244
|
+
}
|
|
245
|
+
return matches.flatMap(c => selectFrom(c, tail));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Parse one step: `name`, `name[@attr=value]`, `name[2]`.
|
|
249
|
+
function parseStep(step) {
|
|
250
|
+
const m = step.match(
|
|
251
|
+
/^([\w:-]+|\*)(?:\[@([\w:-]+)=['"]?([^'"\]]+)['"]?\])?(?:\[(\d+)\])?$/,
|
|
252
|
+
);
|
|
253
|
+
if (!m) return null;
|
|
254
|
+
return {
|
|
255
|
+
name: m[1],
|
|
256
|
+
attr: m[2],
|
|
257
|
+
value: coerce(m[3]),
|
|
258
|
+
index: m[4] ? Number(m[4]) : null,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Equality is "loose" (string comparison) because XML attributes start as
|
|
263
|
+
// strings; we coerce on the right side, so for a predicate like
|
|
264
|
+
// `[@done=true]` against `done="true"` the comparison succeeds.
|
|
265
|
+
function looseEqual(a, b) {
|
|
266
|
+
return String(a) === String(b);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Coerce a string into a primitive: "true"/"false" -> Boolean,
|
|
271
|
+
* "12.5" -> Number, "" -> empty string. Used everywhere a value enters
|
|
272
|
+
* the tree from text.
|
|
273
|
+
*/
|
|
274
|
+
function coerce(value) {
|
|
275
|
+
if (value === true || value === 'true') return true;
|
|
276
|
+
if (value === false || value === 'false') return false;
|
|
277
|
+
if (
|
|
278
|
+
typeof value === 'string' &&
|
|
279
|
+
value.trim() !== '' &&
|
|
280
|
+
/^-?\d+(\.\d+)?$/.test(value)
|
|
281
|
+
) {
|
|
282
|
+
return Number(value);
|
|
283
|
+
}
|
|
284
|
+
return value ?? '';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Read element attributes into a plain object, optionally interpolating
|
|
288
|
+
// `{expr}` placeholders. Skips namespaced attrs (those with `:`) because
|
|
289
|
+
// those carry framework directives like `bind:` / `on:`.
|
|
290
|
+
function attrsObject(element, instance = null, local = {}) {
|
|
291
|
+
const out = {};
|
|
292
|
+
for (const attr of [...element.attributes]) {
|
|
293
|
+
if (attr.name.includes(':')) continue;
|
|
294
|
+
out[attr.name] = instance
|
|
295
|
+
? language.interpolate(attr.value, instance, local, { raw: true })
|
|
296
|
+
: attr.value;
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Recursively turn an XML element tree (as produced by DOMParser) into an
|
|
303
|
+
* XNode tree. `silent: true` skips events while the tree is still empty.
|
|
304
|
+
*/
|
|
305
|
+
function parseDataElement(element, instance = null, local = {}) {
|
|
306
|
+
const node = new XNode(
|
|
307
|
+
element.localName,
|
|
308
|
+
attrsObject(element, instance, local),
|
|
309
|
+
ownText(element),
|
|
310
|
+
);
|
|
311
|
+
for (const child of [...element.children]) {
|
|
312
|
+
node.append(parseDataElement(child, instance, local), { silent: true });
|
|
313
|
+
}
|
|
314
|
+
return node;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Direct text children only - we don't merge descendant text.
|
|
318
|
+
function ownText(element) {
|
|
319
|
+
return [...element.childNodes]
|
|
320
|
+
.filter(n => n.nodeType === Node.TEXT_NODE)
|
|
321
|
+
.map(n => n.textContent)
|
|
322
|
+
.join('')
|
|
323
|
+
.trim();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Expose to other features.
|
|
327
|
+
language.XNode = XNode;
|
|
328
|
+
language.XTree = XTree;
|
|
329
|
+
language.coerce = coerce;
|
|
330
|
+
language.parseDataElement = parseDataElement;
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Self-tests
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
language.test('instance: path selection, predicate, valueOf', () => {
|
|
336
|
+
const root = new XNode('data');
|
|
337
|
+
const tree = new XTree(root);
|
|
338
|
+
root.append(new XNode('todo', { id: 'a', done: 'true' }), { silent: true });
|
|
339
|
+
root.append(new XNode('todo', { id: 'b', done: 'false' }), { silent: true });
|
|
340
|
+
assert(tree.select('/todo[@done=true]').length === 1, 'predicate failed');
|
|
341
|
+
assert(tree.valueOf('/todo[@id=b]/@done') === false, 'valueOf failed');
|
|
342
|
+
});
|
|
343
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// morph.js
|
|
3
|
+
//
|
|
4
|
+
// A small, dependency-free DOM morpher.
|
|
5
|
+
//
|
|
6
|
+
// The Galath rendering pipeline produces a fresh HTML string on every change
|
|
7
|
+
// and would prefer to *replace* the DOM each time. That's catastrophic for UX:
|
|
8
|
+
// inputs lose focus, scroll positions reset, transitions tear. We can't ship
|
|
9
|
+
// that. Instead we compute the new HTML, parse it into an off-screen tree,
|
|
10
|
+
// and surgically rewrite the *live* tree to match the new tree, leaving every
|
|
11
|
+
// untouched node alone.
|
|
12
|
+
//
|
|
13
|
+
// This is the same job morphdom does. We write our own here because the
|
|
14
|
+
// project rule is "no external npm packages". Ours is intentionally short
|
|
15
|
+
// (~80 lines of logic) and tuned for Galath's needs:
|
|
16
|
+
//
|
|
17
|
+
// * Preserve focus. If the user is typing in an <input> when the morph
|
|
18
|
+
// happens, we DO NOT clobber their value or selection. We let the live
|
|
19
|
+
// element keep its state and just sync attributes that aren't user-typed.
|
|
20
|
+
//
|
|
21
|
+
// * Preserve checkbox / radio state in the same way.
|
|
22
|
+
//
|
|
23
|
+
// * Match children purely by position. Galath's renderer emits stable order
|
|
24
|
+
// based on selection results, so positional matching is fine. Keyed
|
|
25
|
+
// reconciliation is intentionally out of scope - if you need stable
|
|
26
|
+
// identity across reorders, write a `<datatemplate key="...">` and rely
|
|
27
|
+
// on the rendering layer to emit a stable order.
|
|
28
|
+
//
|
|
29
|
+
// * Skip subtrees the renderer marked `data-xes-frozen`. (Reserved for
|
|
30
|
+
// future use; right now nothing emits it, but leaving the hook open lets
|
|
31
|
+
// us escape-hatch later without touching every caller.)
|
|
32
|
+
//
|
|
33
|
+
// The exported function `morph(fromNode, toNode)` mutates `fromNode` in place
|
|
34
|
+
// so its content equals `toNode`. It returns nothing.
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
// Element tags whose content is "user input" - we should never overwrite
|
|
38
|
+
// `value`, `selectionStart`, etc. on these while they have focus.
|
|
39
|
+
const FORM_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Morph `fromNode` so it structurally matches `toNode`.
|
|
43
|
+
* Both should be Elements (not DocumentFragments).
|
|
44
|
+
*
|
|
45
|
+
* IMPORTANT: when `fromNode` is a Custom Element (its tag contains a hyphen
|
|
46
|
+
* AND it's a registered element), we sync ATTRIBUTES but never recurse into
|
|
47
|
+
* its children. Custom elements own their internal DOM via their own
|
|
48
|
+
* renderNow; parents must not clobber that. Attribute changes still flow,
|
|
49
|
+
* which lets prop updates re-trigger the child component's render.
|
|
50
|
+
*/
|
|
51
|
+
export function morph(fromNode, toNode) {
|
|
52
|
+
if (fromNode.nodeName !== toNode.nodeName) {
|
|
53
|
+
fromNode.replaceWith(toNode);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
syncAttributes(fromNode, toNode);
|
|
57
|
+
if (isCustomElement(fromNode)) return; // its internals are not ours.
|
|
58
|
+
syncChildren(fromNode, toNode);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isCustomElement(el) {
|
|
62
|
+
if (!el || el.nodeType !== Node.ELEMENT_NODE) return false;
|
|
63
|
+
const name = el.localName;
|
|
64
|
+
return name.includes('-') && !!customElements.get(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// -----------------------------------------------------------------------------
|
|
68
|
+
// Attribute sync
|
|
69
|
+
// -----------------------------------------------------------------------------
|
|
70
|
+
// Walk the new attribute set and copy values across; remove attributes that
|
|
71
|
+
// don't exist in the new tree. We special-case form fields so that an input
|
|
72
|
+
// being edited doesn't have its `value` ripped out from under the user.
|
|
73
|
+
// -----------------------------------------------------------------------------
|
|
74
|
+
function syncAttributes(fromEl, toEl) {
|
|
75
|
+
const isFocusedInput =
|
|
76
|
+
fromEl === document.activeElement && FORM_TAGS.has(fromEl.tagName);
|
|
77
|
+
|
|
78
|
+
// Copy/replace attributes from the new element.
|
|
79
|
+
for (const attr of toEl.attributes) {
|
|
80
|
+
// The DOM `value` attribute is only the *initial* value of an input, but
|
|
81
|
+
// many devs (and Galath bindings) write to .value as a property. If the
|
|
82
|
+
// input is currently focused we leave its live value alone.
|
|
83
|
+
if (isFocusedInput && (attr.name === 'value' || attr.name === 'checked')) continue;
|
|
84
|
+
if (fromEl.getAttribute(attr.name) !== attr.value) {
|
|
85
|
+
fromEl.setAttribute(attr.name, attr.value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Remove attributes that no longer exist in the new tree.
|
|
90
|
+
// We iterate over a snapshot because we mutate during the loop.
|
|
91
|
+
for (const attr of [...fromEl.attributes]) {
|
|
92
|
+
if (!toEl.hasAttribute(attr.name)) {
|
|
93
|
+
if (isFocusedInput && (attr.name === 'value' || attr.name === 'checked')) continue;
|
|
94
|
+
fromEl.removeAttribute(attr.name);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Sync the `value` *property* of form inputs that aren't focused. The
|
|
99
|
+
// attribute alone doesn't update the live input box once it's been typed
|
|
100
|
+
// into.
|
|
101
|
+
if (FORM_TAGS.has(fromEl.tagName) && !isFocusedInput) {
|
|
102
|
+
const newValue = toEl.getAttribute('value') ?? '';
|
|
103
|
+
if (fromEl.value !== newValue) fromEl.value = newValue;
|
|
104
|
+
if ('checked' in fromEl) {
|
|
105
|
+
const newChecked = toEl.hasAttribute('checked');
|
|
106
|
+
if (fromEl.checked !== newChecked) fromEl.checked = newChecked;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// -----------------------------------------------------------------------------
|
|
112
|
+
// Children sync
|
|
113
|
+
// -----------------------------------------------------------------------------
|
|
114
|
+
// Pair children by index. Where a child changes type (e.g. <span> became
|
|
115
|
+
// <div>), we replace it. Where it's the same tag, we recurse. Where the new
|
|
116
|
+
// tree has fewer children, we trim. Where it has more, we append.
|
|
117
|
+
//
|
|
118
|
+
// Text nodes are handled specially because they can't be morphed - we just
|
|
119
|
+
// overwrite their `data` property when it differs.
|
|
120
|
+
// -----------------------------------------------------------------------------
|
|
121
|
+
function syncChildren(fromEl, toEl) {
|
|
122
|
+
const fromChildren = [...fromEl.childNodes];
|
|
123
|
+
const toChildren = [...toEl.childNodes];
|
|
124
|
+
|
|
125
|
+
// Keyed fast path: when *every* element child of the new tree carries
|
|
126
|
+
// `data-xes-key`, reconcile by key instead of by position. This survives
|
|
127
|
+
// reorders without churning DOM that just changed places.
|
|
128
|
+
if (allKeyed(toChildren)) {
|
|
129
|
+
syncKeyedChildren(fromEl, fromChildren, toChildren);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const max = Math.max(fromChildren.length, toChildren.length);
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < max; i++) {
|
|
136
|
+
const fromChild = fromChildren[i];
|
|
137
|
+
const toChild = toChildren[i];
|
|
138
|
+
|
|
139
|
+
if (!toChild) {
|
|
140
|
+
// New tree is shorter - drop the extra live node.
|
|
141
|
+
fromChild.remove();
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!fromChild) {
|
|
146
|
+
// New tree is longer - append the new node (cloned so the off-screen
|
|
147
|
+
// template stays reusable).
|
|
148
|
+
fromEl.appendChild(toChild.cloneNode(true));
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Both exist at this index. Decide what to do based on node types.
|
|
153
|
+
if (fromChild.nodeType !== toChild.nodeType) {
|
|
154
|
+
// E.g. text became element. Just replace.
|
|
155
|
+
fromChild.replaceWith(toChild.cloneNode(true));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (fromChild.nodeType === Node.TEXT_NODE) {
|
|
160
|
+
if (fromChild.data !== toChild.data) fromChild.data = toChild.data;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (fromChild.nodeType === Node.COMMENT_NODE) {
|
|
165
|
+
// Comments rarely matter for behavior; sync data anyway.
|
|
166
|
+
if (fromChild.data !== toChild.data) fromChild.data = toChild.data;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (fromChild.nodeType === Node.ELEMENT_NODE) {
|
|
171
|
+
// Future hook: a renderer can mark a subtree `data-xes-frozen` to opt
|
|
172
|
+
// out of morphing. Today nothing emits it. Cheap to keep.
|
|
173
|
+
if (fromChild.hasAttribute && fromChild.hasAttribute('data-xes-frozen')) continue;
|
|
174
|
+
|
|
175
|
+
if (fromChild.nodeName !== toChild.nodeName) {
|
|
176
|
+
fromChild.replaceWith(toChild.cloneNode(true));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Same tag. Recurse.
|
|
181
|
+
morph(fromChild, toChild);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Returns true when there is at least one element child and every element
|
|
187
|
+
// child has `data-xes-key`. Non-element nodes (text, comments) are ignored
|
|
188
|
+
// for this decision but are dropped during the keyed sync.
|
|
189
|
+
function allKeyed(nodes) {
|
|
190
|
+
let any = false;
|
|
191
|
+
for (const n of nodes) {
|
|
192
|
+
if (n.nodeType !== Node.ELEMENT_NODE) continue;
|
|
193
|
+
if (!n.hasAttribute || !n.hasAttribute('data-xes-key')) return false;
|
|
194
|
+
any = true;
|
|
195
|
+
}
|
|
196
|
+
return any;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Reorder `fromEl`'s children to match `toChildren` by key. Existing nodes
|
|
200
|
+
// are MOVED (not recreated) so their internal state - input value, scroll,
|
|
201
|
+
// focus, attached listeners - survives a list reorder. Missing keys are
|
|
202
|
+
// inserted from a deep clone of the new template; surplus keyed nodes are
|
|
203
|
+
// removed.
|
|
204
|
+
function syncKeyedChildren(fromEl, fromChildren, toChildren) {
|
|
205
|
+
// Drop any non-keyed live children up front; they don't survive keying.
|
|
206
|
+
for (const child of fromChildren) {
|
|
207
|
+
if (child.nodeType !== Node.ELEMENT_NODE) {
|
|
208
|
+
child.remove();
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!child.hasAttribute('data-xes-key')) child.remove();
|
|
212
|
+
}
|
|
213
|
+
const byKey = new Map();
|
|
214
|
+
for (const child of [...fromEl.childNodes]) {
|
|
215
|
+
if (child.nodeType === Node.ELEMENT_NODE && child.hasAttribute('data-xes-key')) {
|
|
216
|
+
byKey.set(child.getAttribute('data-xes-key'), child);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const used = new Set();
|
|
220
|
+
for (const newChild of toChildren) {
|
|
221
|
+
if (newChild.nodeType !== Node.ELEMENT_NODE) continue;
|
|
222
|
+
const key = newChild.getAttribute('data-xes-key');
|
|
223
|
+
const existing = byKey.get(key);
|
|
224
|
+
if (existing) {
|
|
225
|
+
// appendChild on a child already inside fromEl moves it, which is
|
|
226
|
+
// exactly what we want for "place at the next position".
|
|
227
|
+
fromEl.appendChild(existing);
|
|
228
|
+
morph(existing, newChild);
|
|
229
|
+
used.add(key);
|
|
230
|
+
} else {
|
|
231
|
+
fromEl.appendChild(newChild.cloneNode(true));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const [key, el] of byKey) {
|
|
235
|
+
if (!used.has(key) && el.parentNode === fromEl) el.remove();
|
|
236
|
+
}
|
|
237
|
+
}
|