zero-query 0.6.3 → 0.8.6
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/README.md +39 -29
- package/cli/commands/build.js +113 -4
- package/cli/commands/bundle.js +392 -29
- package/cli/commands/create.js +1 -1
- package/cli/commands/dev/devtools/index.js +56 -0
- package/cli/commands/dev/devtools/js/components.js +49 -0
- package/cli/commands/dev/devtools/js/core.js +409 -0
- package/cli/commands/dev/devtools/js/elements.js +413 -0
- package/cli/commands/dev/devtools/js/network.js +166 -0
- package/cli/commands/dev/devtools/js/performance.js +73 -0
- package/cli/commands/dev/devtools/js/router.js +105 -0
- package/cli/commands/dev/devtools/js/source.js +132 -0
- package/cli/commands/dev/devtools/js/stats.js +35 -0
- package/cli/commands/dev/devtools/js/tabs.js +79 -0
- package/cli/commands/dev/devtools/panel.html +95 -0
- package/cli/commands/dev/devtools/styles.css +244 -0
- package/cli/commands/dev/index.js +29 -4
- package/cli/commands/dev/logger.js +6 -1
- package/cli/commands/dev/overlay.js +428 -2
- package/cli/commands/dev/server.js +42 -5
- package/cli/commands/dev/watcher.js +59 -1
- package/cli/help.js +8 -5
- package/cli/scaffold/{scripts → app}/app.js +16 -23
- package/cli/scaffold/{scripts → app}/components/about.js +4 -4
- package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
- package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -7
- package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +3 -3
- package/cli/scaffold/app/components/home.js +137 -0
- package/cli/scaffold/{scripts → app}/routes.js +1 -1
- package/cli/scaffold/{scripts → app}/store.js +6 -6
- package/cli/scaffold/assets/.gitkeep +0 -0
- package/cli/scaffold/{styles/styles.css → global.css} +4 -2
- package/cli/scaffold/index.html +12 -11
- package/cli/utils.js +111 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +1122 -158
- package/dist/zquery.min.js +3 -16
- package/index.d.ts +129 -1290
- package/index.js +15 -10
- package/package.json +7 -6
- package/src/component.js +172 -49
- package/src/core.js +359 -18
- package/src/diff.js +256 -58
- package/src/expression.js +33 -3
- package/src/reactive.js +37 -5
- package/src/router.js +243 -7
- package/tests/component.test.js +886 -0
- package/tests/core.test.js +977 -0
- package/tests/diff.test.js +525 -0
- package/tests/errors.test.js +162 -0
- package/tests/expression.test.js +482 -0
- package/tests/http.test.js +289 -0
- package/tests/reactive.test.js +339 -0
- package/tests/router.test.js +649 -0
- package/tests/store.test.js +379 -0
- package/tests/utils.test.js +512 -0
- package/types/collection.d.ts +383 -0
- package/types/component.d.ts +217 -0
- package/types/errors.d.ts +103 -0
- package/types/http.d.ts +81 -0
- package/types/misc.d.ts +179 -0
- package/types/reactive.d.ts +76 -0
- package/types/router.d.ts +161 -0
- package/types/ssr.d.ts +49 -0
- package/types/store.d.ts +107 -0
- package/types/utils.d.ts +142 -0
- package/cli/commands/dev.old.js +0 -520
- package/cli/scaffold/scripts/components/home.js +0 -137
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
package/dist/zquery.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* zQuery (zeroQuery) v0.6
|
|
2
|
+
* zQuery (zeroQuery) v0.8.6
|
|
3
3
|
* Lightweight Frontend Library
|
|
4
4
|
* https://github.com/tonywied17/zero-query
|
|
5
|
-
* (c) 2026 Anthony Wiedman
|
|
5
|
+
* (c) 2026 Anthony Wiedman - MIT License
|
|
6
6
|
*/
|
|
7
7
|
(function(global) {
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
10
|
-
// --- src/errors.js
|
|
10
|
+
// --- src/errors.js -----------------------------------------------
|
|
11
11
|
/**
|
|
12
12
|
* zQuery Errors — Structured error handling system
|
|
13
13
|
*
|
|
@@ -164,7 +164,7 @@ function validate(value, name, expectedType) {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
// --- src/reactive.js
|
|
167
|
+
// --- src/reactive.js ---------------------------------------------
|
|
168
168
|
/**
|
|
169
169
|
* zQuery Reactive — Proxy-based deep reactivity system
|
|
170
170
|
*
|
|
@@ -205,6 +205,8 @@ function reactive(target, onChange, _path = '') {
|
|
|
205
205
|
const old = obj[key];
|
|
206
206
|
if (old === value) return true;
|
|
207
207
|
obj[key] = value;
|
|
208
|
+
// Invalidate proxy cache for the old value (it may have been replaced)
|
|
209
|
+
if (old && typeof old === 'object') proxyCache.delete(old);
|
|
208
210
|
try {
|
|
209
211
|
onChange(key, value, old);
|
|
210
212
|
} catch (err) {
|
|
@@ -216,6 +218,7 @@ function reactive(target, onChange, _path = '') {
|
|
|
216
218
|
deleteProperty(obj, key) {
|
|
217
219
|
const old = obj[key];
|
|
218
220
|
delete obj[key];
|
|
221
|
+
if (old && typeof old === 'object') proxyCache.delete(old);
|
|
219
222
|
try {
|
|
220
223
|
onChange(key, undefined, old);
|
|
221
224
|
} catch (err) {
|
|
@@ -242,6 +245,10 @@ class Signal {
|
|
|
242
245
|
// Track dependency if there's an active effect
|
|
243
246
|
if (Signal._activeEffect) {
|
|
244
247
|
this._subscribers.add(Signal._activeEffect);
|
|
248
|
+
// Record this signal in the effect's dependency set for proper cleanup
|
|
249
|
+
if (Signal._activeEffect._deps) {
|
|
250
|
+
Signal._activeEffect._deps.add(this);
|
|
251
|
+
}
|
|
245
252
|
}
|
|
246
253
|
return this._value;
|
|
247
254
|
}
|
|
@@ -255,12 +262,15 @@ class Signal {
|
|
|
255
262
|
peek() { return this._value; }
|
|
256
263
|
|
|
257
264
|
_notify() {
|
|
258
|
-
|
|
259
|
-
|
|
265
|
+
// Snapshot subscribers before iterating — a subscriber might modify
|
|
266
|
+
// the set (e.g., an effect re-running, adding itself back)
|
|
267
|
+
const subs = [...this._subscribers];
|
|
268
|
+
for (let i = 0; i < subs.length; i++) {
|
|
269
|
+
try { subs[i](); }
|
|
260
270
|
catch (err) {
|
|
261
271
|
reportError(ErrorCode.SIGNAL_CALLBACK, 'Signal subscriber threw', { signal: this }, err);
|
|
262
272
|
}
|
|
263
|
-
}
|
|
273
|
+
}
|
|
264
274
|
}
|
|
265
275
|
|
|
266
276
|
subscribe(fn) {
|
|
@@ -295,12 +305,24 @@ function computed(fn) {
|
|
|
295
305
|
}
|
|
296
306
|
|
|
297
307
|
/**
|
|
298
|
-
* Create a side-effect that auto-tracks signal dependencies
|
|
308
|
+
* Create a side-effect that auto-tracks signal dependencies.
|
|
309
|
+
* Returns a dispose function that removes the effect from all
|
|
310
|
+
* signals it subscribed to — prevents memory leaks.
|
|
311
|
+
*
|
|
299
312
|
* @param {Function} fn — effect function
|
|
300
313
|
* @returns {Function} — dispose function
|
|
301
314
|
*/
|
|
302
315
|
function effect(fn) {
|
|
303
316
|
const execute = () => {
|
|
317
|
+
// Clean up old subscriptions before re-running so stale
|
|
318
|
+
// dependencies from a previous run are properly removed
|
|
319
|
+
if (execute._deps) {
|
|
320
|
+
for (const sig of execute._deps) {
|
|
321
|
+
sig._subscribers.delete(execute);
|
|
322
|
+
}
|
|
323
|
+
execute._deps.clear();
|
|
324
|
+
}
|
|
325
|
+
|
|
304
326
|
Signal._activeEffect = execute;
|
|
305
327
|
try { fn(); }
|
|
306
328
|
catch (err) {
|
|
@@ -308,14 +330,24 @@ function effect(fn) {
|
|
|
308
330
|
}
|
|
309
331
|
finally { Signal._activeEffect = null; }
|
|
310
332
|
};
|
|
333
|
+
|
|
334
|
+
// Track which signals this effect reads from
|
|
335
|
+
execute._deps = new Set();
|
|
336
|
+
|
|
311
337
|
execute();
|
|
312
338
|
return () => {
|
|
313
|
-
//
|
|
339
|
+
// Dispose: remove this effect from every signal it subscribed to
|
|
340
|
+
if (execute._deps) {
|
|
341
|
+
for (const sig of execute._deps) {
|
|
342
|
+
sig._subscribers.delete(execute);
|
|
343
|
+
}
|
|
344
|
+
execute._deps.clear();
|
|
345
|
+
}
|
|
314
346
|
Signal._activeEffect = null;
|
|
315
347
|
};
|
|
316
348
|
}
|
|
317
349
|
|
|
318
|
-
// --- src/core.js
|
|
350
|
+
// --- src/core.js -------------------------------------------------
|
|
319
351
|
/**
|
|
320
352
|
* zQuery Core — Selector engine & chainable DOM collection
|
|
321
353
|
*
|
|
@@ -323,6 +355,7 @@ function effect(fn) {
|
|
|
323
355
|
* into a full jQuery-like chainable wrapper with modern APIs.
|
|
324
356
|
*/
|
|
325
357
|
|
|
358
|
+
|
|
326
359
|
// ---------------------------------------------------------------------------
|
|
327
360
|
// ZQueryCollection — wraps an array of elements with chainable methods
|
|
328
361
|
// ---------------------------------------------------------------------------
|
|
@@ -393,8 +426,96 @@ class ZQueryCollection {
|
|
|
393
426
|
return new ZQueryCollection(sibs);
|
|
394
427
|
}
|
|
395
428
|
|
|
396
|
-
next() {
|
|
397
|
-
|
|
429
|
+
next(selector) {
|
|
430
|
+
const els = this.elements.map(el => el.nextElementSibling).filter(Boolean);
|
|
431
|
+
return new ZQueryCollection(selector ? els.filter(el => el.matches(selector)) : els);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
prev(selector) {
|
|
435
|
+
const els = this.elements.map(el => el.previousElementSibling).filter(Boolean);
|
|
436
|
+
return new ZQueryCollection(selector ? els.filter(el => el.matches(selector)) : els);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
nextAll(selector) {
|
|
440
|
+
const result = [];
|
|
441
|
+
this.elements.forEach(el => {
|
|
442
|
+
let sib = el.nextElementSibling;
|
|
443
|
+
while (sib) {
|
|
444
|
+
if (!selector || sib.matches(selector)) result.push(sib);
|
|
445
|
+
sib = sib.nextElementSibling;
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
return new ZQueryCollection(result);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
nextUntil(selector, filter) {
|
|
452
|
+
const result = [];
|
|
453
|
+
this.elements.forEach(el => {
|
|
454
|
+
let sib = el.nextElementSibling;
|
|
455
|
+
while (sib) {
|
|
456
|
+
if (selector && sib.matches(selector)) break;
|
|
457
|
+
if (!filter || sib.matches(filter)) result.push(sib);
|
|
458
|
+
sib = sib.nextElementSibling;
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
return new ZQueryCollection(result);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
prevAll(selector) {
|
|
465
|
+
const result = [];
|
|
466
|
+
this.elements.forEach(el => {
|
|
467
|
+
let sib = el.previousElementSibling;
|
|
468
|
+
while (sib) {
|
|
469
|
+
if (!selector || sib.matches(selector)) result.push(sib);
|
|
470
|
+
sib = sib.previousElementSibling;
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
return new ZQueryCollection(result);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
prevUntil(selector, filter) {
|
|
477
|
+
const result = [];
|
|
478
|
+
this.elements.forEach(el => {
|
|
479
|
+
let sib = el.previousElementSibling;
|
|
480
|
+
while (sib) {
|
|
481
|
+
if (selector && sib.matches(selector)) break;
|
|
482
|
+
if (!filter || sib.matches(filter)) result.push(sib);
|
|
483
|
+
sib = sib.previousElementSibling;
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
return new ZQueryCollection(result);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
parents(selector) {
|
|
490
|
+
const result = [];
|
|
491
|
+
this.elements.forEach(el => {
|
|
492
|
+
let parent = el.parentElement;
|
|
493
|
+
while (parent) {
|
|
494
|
+
if (!selector || parent.matches(selector)) result.push(parent);
|
|
495
|
+
parent = parent.parentElement;
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
return new ZQueryCollection([...new Set(result)]);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
parentsUntil(selector, filter) {
|
|
502
|
+
const result = [];
|
|
503
|
+
this.elements.forEach(el => {
|
|
504
|
+
let parent = el.parentElement;
|
|
505
|
+
while (parent) {
|
|
506
|
+
if (selector && parent.matches(selector)) break;
|
|
507
|
+
if (!filter || parent.matches(filter)) result.push(parent);
|
|
508
|
+
parent = parent.parentElement;
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
return new ZQueryCollection([...new Set(result)]);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
contents() {
|
|
515
|
+
const result = [];
|
|
516
|
+
this.elements.forEach(el => result.push(...el.childNodes));
|
|
517
|
+
return new ZQueryCollection(result);
|
|
518
|
+
}
|
|
398
519
|
|
|
399
520
|
filter(selector) {
|
|
400
521
|
if (typeof selector === 'function') {
|
|
@@ -414,20 +535,85 @@ class ZQueryCollection {
|
|
|
414
535
|
return new ZQueryCollection(this.elements.filter(el => el.querySelector(selector)));
|
|
415
536
|
}
|
|
416
537
|
|
|
538
|
+
is(selector) {
|
|
539
|
+
if (typeof selector === 'function') {
|
|
540
|
+
return this.elements.some((el, i) => selector.call(el, i, el));
|
|
541
|
+
}
|
|
542
|
+
return this.elements.some(el => el.matches(selector));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
slice(start, end) {
|
|
546
|
+
return new ZQueryCollection(this.elements.slice(start, end));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
add(selector, context) {
|
|
550
|
+
const toAdd = (selector instanceof ZQueryCollection)
|
|
551
|
+
? selector.elements
|
|
552
|
+
: (selector instanceof Node)
|
|
553
|
+
? [selector]
|
|
554
|
+
: Array.from((context || document).querySelectorAll(selector));
|
|
555
|
+
return new ZQueryCollection([...this.elements, ...toAdd]);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
get(index) {
|
|
559
|
+
if (index === undefined) return [...this.elements];
|
|
560
|
+
return index < 0 ? this.elements[this.length + index] : this.elements[index];
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
index(selector) {
|
|
564
|
+
if (selector === undefined) {
|
|
565
|
+
const el = this.first();
|
|
566
|
+
return el ? Array.from(el.parentElement.children).indexOf(el) : -1;
|
|
567
|
+
}
|
|
568
|
+
const target = (typeof selector === 'string')
|
|
569
|
+
? document.querySelector(selector)
|
|
570
|
+
: selector;
|
|
571
|
+
return this.elements.indexOf(target);
|
|
572
|
+
}
|
|
573
|
+
|
|
417
574
|
// --- Classes -------------------------------------------------------------
|
|
418
575
|
|
|
419
576
|
addClass(...names) {
|
|
577
|
+
// Fast path: single class, no spaces — avoids flatMap + regex split allocation
|
|
578
|
+
if (names.length === 1 && names[0].indexOf(' ') === -1) {
|
|
579
|
+
const c = names[0];
|
|
580
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(c);
|
|
581
|
+
return this;
|
|
582
|
+
}
|
|
420
583
|
const classes = names.flatMap(n => n.split(/\s+/));
|
|
421
|
-
|
|
584
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(...classes);
|
|
585
|
+
return this;
|
|
422
586
|
}
|
|
423
587
|
|
|
424
588
|
removeClass(...names) {
|
|
589
|
+
if (names.length === 1 && names[0].indexOf(' ') === -1) {
|
|
590
|
+
const c = names[0];
|
|
591
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(c);
|
|
592
|
+
return this;
|
|
593
|
+
}
|
|
425
594
|
const classes = names.flatMap(n => n.split(/\s+/));
|
|
426
|
-
|
|
595
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(...classes);
|
|
596
|
+
return this;
|
|
427
597
|
}
|
|
428
598
|
|
|
429
|
-
toggleClass(
|
|
430
|
-
|
|
599
|
+
toggleClass(...args) {
|
|
600
|
+
const force = typeof args[args.length - 1] === 'boolean' ? args.pop() : undefined;
|
|
601
|
+
// Fast path: single class, no spaces
|
|
602
|
+
if (args.length === 1 && args[0].indexOf(' ') === -1) {
|
|
603
|
+
const c = args[0];
|
|
604
|
+
for (let i = 0; i < this.elements.length; i++) {
|
|
605
|
+
force !== undefined ? this.elements[i].classList.toggle(c, force) : this.elements[i].classList.toggle(c);
|
|
606
|
+
}
|
|
607
|
+
return this;
|
|
608
|
+
}
|
|
609
|
+
const classes = args.flatMap(n => n.split(/\s+/));
|
|
610
|
+
for (let i = 0; i < this.elements.length; i++) {
|
|
611
|
+
const el = this.elements[i];
|
|
612
|
+
for (let j = 0; j < classes.length; j++) {
|
|
613
|
+
force !== undefined ? el.classList.toggle(classes[j], force) : el.classList.toggle(classes[j]);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return this;
|
|
431
617
|
}
|
|
432
618
|
|
|
433
619
|
hasClass(name) {
|
|
@@ -463,7 +649,8 @@ class ZQueryCollection {
|
|
|
463
649
|
|
|
464
650
|
css(props) {
|
|
465
651
|
if (typeof props === 'string') {
|
|
466
|
-
|
|
652
|
+
const el = this.first();
|
|
653
|
+
return el ? getComputedStyle(el)[props] : undefined;
|
|
467
654
|
}
|
|
468
655
|
return this.each((_, el) => Object.assign(el.style, props));
|
|
469
656
|
}
|
|
@@ -481,11 +668,79 @@ class ZQueryCollection {
|
|
|
481
668
|
return el ? { top: el.offsetTop, left: el.offsetLeft } : null;
|
|
482
669
|
}
|
|
483
670
|
|
|
671
|
+
scrollTop(value) {
|
|
672
|
+
if (value === undefined) {
|
|
673
|
+
const el = this.first();
|
|
674
|
+
return el === window ? window.scrollY : el?.scrollTop;
|
|
675
|
+
}
|
|
676
|
+
return this.each((_, el) => {
|
|
677
|
+
if (el === window) window.scrollTo(window.scrollX, value);
|
|
678
|
+
else el.scrollTop = value;
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
scrollLeft(value) {
|
|
683
|
+
if (value === undefined) {
|
|
684
|
+
const el = this.first();
|
|
685
|
+
return el === window ? window.scrollX : el?.scrollLeft;
|
|
686
|
+
}
|
|
687
|
+
return this.each((_, el) => {
|
|
688
|
+
if (el === window) window.scrollTo(value, window.scrollY);
|
|
689
|
+
else el.scrollLeft = value;
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
innerWidth() {
|
|
694
|
+
const el = this.first();
|
|
695
|
+
return el?.clientWidth;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
innerHeight() {
|
|
699
|
+
const el = this.first();
|
|
700
|
+
return el?.clientHeight;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
outerWidth(includeMargin = false) {
|
|
704
|
+
const el = this.first();
|
|
705
|
+
if (!el) return undefined;
|
|
706
|
+
let w = el.offsetWidth;
|
|
707
|
+
if (includeMargin) {
|
|
708
|
+
const style = getComputedStyle(el);
|
|
709
|
+
w += parseFloat(style.marginLeft) + parseFloat(style.marginRight);
|
|
710
|
+
}
|
|
711
|
+
return w;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
outerHeight(includeMargin = false) {
|
|
715
|
+
const el = this.first();
|
|
716
|
+
if (!el) return undefined;
|
|
717
|
+
let h = el.offsetHeight;
|
|
718
|
+
if (includeMargin) {
|
|
719
|
+
const style = getComputedStyle(el);
|
|
720
|
+
h += parseFloat(style.marginTop) + parseFloat(style.marginBottom);
|
|
721
|
+
}
|
|
722
|
+
return h;
|
|
723
|
+
}
|
|
724
|
+
|
|
484
725
|
// --- Content -------------------------------------------------------------
|
|
485
726
|
|
|
486
727
|
html(content) {
|
|
487
728
|
if (content === undefined) return this.first()?.innerHTML;
|
|
488
|
-
|
|
729
|
+
// Auto-morph: if the element already has children, use the diff engine
|
|
730
|
+
// to patch the DOM (preserves focus, scroll, state, keyed reorder via LIS).
|
|
731
|
+
// Empty elements get raw innerHTML for fast first-paint — same strategy
|
|
732
|
+
// the component system uses (first render = innerHTML, updates = morph).
|
|
733
|
+
return this.each((_, el) => {
|
|
734
|
+
if (el.childNodes.length > 0) {
|
|
735
|
+
_morph(el, content);
|
|
736
|
+
} else {
|
|
737
|
+
el.innerHTML = content;
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
morph(content) {
|
|
743
|
+
return this.each((_, el) => { _morph(el, content); });
|
|
489
744
|
}
|
|
490
745
|
|
|
491
746
|
text(content) {
|
|
@@ -542,7 +797,8 @@ class ZQueryCollection {
|
|
|
542
797
|
}
|
|
543
798
|
|
|
544
799
|
empty() {
|
|
545
|
-
|
|
800
|
+
// textContent = '' clears all children without invoking the HTML parser
|
|
801
|
+
return this.each((_, el) => { el.textContent = ''; });
|
|
546
802
|
}
|
|
547
803
|
|
|
548
804
|
clone(deep = true) {
|
|
@@ -552,14 +808,82 @@ class ZQueryCollection {
|
|
|
552
808
|
replaceWith(content) {
|
|
553
809
|
return this.each((_, el) => {
|
|
554
810
|
if (typeof content === 'string') {
|
|
555
|
-
|
|
556
|
-
|
|
811
|
+
// Auto-morph: diff attributes + children when the tag name matches
|
|
812
|
+
// instead of destroying and re-creating the element.
|
|
813
|
+
_morphElement(el, content);
|
|
557
814
|
} else if (content instanceof Node) {
|
|
558
815
|
el.parentNode.replaceChild(content, el);
|
|
559
816
|
}
|
|
560
817
|
});
|
|
561
818
|
}
|
|
562
819
|
|
|
820
|
+
appendTo(target) {
|
|
821
|
+
const dest = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
|
|
822
|
+
if (dest) this.each((_, el) => dest.appendChild(el));
|
|
823
|
+
return this;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
prependTo(target) {
|
|
827
|
+
const dest = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
|
|
828
|
+
if (dest) this.each((_, el) => dest.insertBefore(el, dest.firstChild));
|
|
829
|
+
return this;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
insertAfter(target) {
|
|
833
|
+
const ref = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
|
|
834
|
+
if (ref && ref.parentNode) this.each((_, el) => ref.parentNode.insertBefore(el, ref.nextSibling));
|
|
835
|
+
return this;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
insertBefore(target) {
|
|
839
|
+
const ref = typeof target === 'string' ? document.querySelector(target) : target instanceof ZQueryCollection ? target.first() : target;
|
|
840
|
+
if (ref && ref.parentNode) this.each((_, el) => ref.parentNode.insertBefore(el, ref));
|
|
841
|
+
return this;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
replaceAll(target) {
|
|
845
|
+
const targets = typeof target === 'string'
|
|
846
|
+
? Array.from(document.querySelectorAll(target))
|
|
847
|
+
: target instanceof ZQueryCollection ? target.elements : [target];
|
|
848
|
+
targets.forEach((t, i) => {
|
|
849
|
+
const nodes = i === 0 ? this.elements : this.elements.map(el => el.cloneNode(true));
|
|
850
|
+
nodes.forEach(el => t.parentNode.insertBefore(el, t));
|
|
851
|
+
t.remove();
|
|
852
|
+
});
|
|
853
|
+
return this;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
unwrap(selector) {
|
|
857
|
+
this.elements.forEach(el => {
|
|
858
|
+
const parent = el.parentElement;
|
|
859
|
+
if (!parent || parent === document.body) return;
|
|
860
|
+
if (selector && !parent.matches(selector)) return;
|
|
861
|
+
parent.replaceWith(...parent.childNodes);
|
|
862
|
+
});
|
|
863
|
+
return this;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
wrapAll(wrapper) {
|
|
867
|
+
const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
|
|
868
|
+
const first = this.first();
|
|
869
|
+
if (!first) return this;
|
|
870
|
+
first.parentNode.insertBefore(w, first);
|
|
871
|
+
this.each((_, el) => w.appendChild(el));
|
|
872
|
+
return this;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
wrapInner(wrapper) {
|
|
876
|
+
return this.each((_, el) => {
|
|
877
|
+
const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
|
|
878
|
+
while (el.firstChild) w.appendChild(el.firstChild);
|
|
879
|
+
el.appendChild(w);
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
detach() {
|
|
884
|
+
return this.each((_, el) => el.remove());
|
|
885
|
+
}
|
|
886
|
+
|
|
563
887
|
// --- Visibility ----------------------------------------------------------
|
|
564
888
|
|
|
565
889
|
show(display = '') {
|
|
@@ -572,7 +896,9 @@ class ZQueryCollection {
|
|
|
572
896
|
|
|
573
897
|
toggle(display = '') {
|
|
574
898
|
return this.each((_, el) => {
|
|
575
|
-
|
|
899
|
+
// Check inline style first (cheap) before forcing layout via getComputedStyle
|
|
900
|
+
const hidden = el.style.display === 'none' || (el.style.display !== '' ? false : getComputedStyle(el).display === 'none');
|
|
901
|
+
el.style.display = hidden ? display : 'none';
|
|
576
902
|
});
|
|
577
903
|
}
|
|
578
904
|
|
|
@@ -585,9 +911,10 @@ class ZQueryCollection {
|
|
|
585
911
|
events.forEach(evt => {
|
|
586
912
|
if (typeof selectorOrHandler === 'function') {
|
|
587
913
|
el.addEventListener(evt, selectorOrHandler);
|
|
588
|
-
} else {
|
|
589
|
-
// Delegated event
|
|
914
|
+
} else if (typeof selectorOrHandler === 'string') {
|
|
915
|
+
// Delegated event — only works on elements that support closest()
|
|
590
916
|
el.addEventListener(evt, (e) => {
|
|
917
|
+
if (!e.target || typeof e.target.closest !== 'function') return;
|
|
591
918
|
const target = e.target.closest(selectorOrHandler);
|
|
592
919
|
if (target && el.contains(target)) handler.call(target, e);
|
|
593
920
|
});
|
|
@@ -620,6 +947,10 @@ class ZQueryCollection {
|
|
|
620
947
|
submit(fn) { return fn ? this.on('submit', fn) : this.trigger('submit'); }
|
|
621
948
|
focus() { this.first()?.focus(); return this; }
|
|
622
949
|
blur() { this.first()?.blur(); return this; }
|
|
950
|
+
hover(enterFn, leaveFn) {
|
|
951
|
+
this.on('mouseenter', enterFn);
|
|
952
|
+
return this.on('mouseleave', leaveFn || enterFn);
|
|
953
|
+
}
|
|
623
954
|
|
|
624
955
|
// --- Animation -----------------------------------------------------------
|
|
625
956
|
|
|
@@ -651,6 +982,40 @@ class ZQueryCollection {
|
|
|
651
982
|
return this.animate({ opacity: '0' }, duration).then(col => col.hide());
|
|
652
983
|
}
|
|
653
984
|
|
|
985
|
+
fadeToggle(duration = 300) {
|
|
986
|
+
return Promise.all(this.elements.map(el => {
|
|
987
|
+
const visible = getComputedStyle(el).opacity !== '0' && getComputedStyle(el).display !== 'none';
|
|
988
|
+
const col = new ZQueryCollection([el]);
|
|
989
|
+
return visible ? col.fadeOut(duration) : col.fadeIn(duration);
|
|
990
|
+
})).then(() => this);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
fadeTo(duration, opacity) {
|
|
994
|
+
return this.animate({ opacity: String(opacity) }, duration);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
slideDown(duration = 300) {
|
|
998
|
+
return this.each((_, el) => {
|
|
999
|
+
el.style.display = '';
|
|
1000
|
+
el.style.overflow = 'hidden';
|
|
1001
|
+
const h = el.scrollHeight + 'px';
|
|
1002
|
+
el.style.maxHeight = '0';
|
|
1003
|
+
el.style.transition = `max-height ${duration}ms ease`;
|
|
1004
|
+
requestAnimationFrame(() => { el.style.maxHeight = h; });
|
|
1005
|
+
setTimeout(() => { el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
slideUp(duration = 300) {
|
|
1010
|
+
return this.each((_, el) => {
|
|
1011
|
+
el.style.overflow = 'hidden';
|
|
1012
|
+
el.style.maxHeight = el.scrollHeight + 'px';
|
|
1013
|
+
el.style.transition = `max-height ${duration}ms ease`;
|
|
1014
|
+
requestAnimationFrame(() => { el.style.maxHeight = '0'; });
|
|
1015
|
+
setTimeout(() => { el.style.display = 'none'; el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
654
1019
|
slideToggle(duration = 300) {
|
|
655
1020
|
return this.each((_, el) => {
|
|
656
1021
|
if (el.style.display === 'none' || getComputedStyle(el).display === 'none') {
|
|
@@ -798,7 +1163,7 @@ query.children = (parentId) => {
|
|
|
798
1163
|
return new ZQueryCollection(p ? Array.from(p.children) : []);
|
|
799
1164
|
};
|
|
800
1165
|
|
|
801
|
-
// Create element shorthand
|
|
1166
|
+
// Create element shorthand — returns ZQueryCollection for chaining
|
|
802
1167
|
query.create = (tag, attrs = {}, ...children) => {
|
|
803
1168
|
const el = document.createElement(tag);
|
|
804
1169
|
for (const [k, v] of Object.entries(attrs)) {
|
|
@@ -812,7 +1177,7 @@ query.create = (tag, attrs = {}, ...children) => {
|
|
|
812
1177
|
if (typeof child === 'string') el.appendChild(document.createTextNode(child));
|
|
813
1178
|
else if (child instanceof Node) el.appendChild(child);
|
|
814
1179
|
});
|
|
815
|
-
return el;
|
|
1180
|
+
return new ZQueryCollection(el);
|
|
816
1181
|
};
|
|
817
1182
|
|
|
818
1183
|
// DOM ready
|
|
@@ -821,17 +1186,24 @@ query.ready = (fn) => {
|
|
|
821
1186
|
else document.addEventListener('DOMContentLoaded', fn);
|
|
822
1187
|
};
|
|
823
1188
|
|
|
824
|
-
// Global event listeners — supports direct and
|
|
1189
|
+
// Global event listeners — supports direct, delegated, and target-bound forms
|
|
825
1190
|
// $.on('keydown', handler) → direct listener on document
|
|
826
1191
|
// $.on('click', '.btn', handler) → delegated via closest()
|
|
1192
|
+
// $.on('scroll', window, handler) → direct listener on target
|
|
827
1193
|
query.on = (event, selectorOrHandler, handler) => {
|
|
828
1194
|
if (typeof selectorOrHandler === 'function') {
|
|
829
1195
|
// 2-arg: direct document listener (keydown, resize, etc.)
|
|
830
1196
|
document.addEventListener(event, selectorOrHandler);
|
|
831
1197
|
return;
|
|
832
1198
|
}
|
|
833
|
-
//
|
|
1199
|
+
// EventTarget (window, element, etc.) — direct listener on target
|
|
1200
|
+
if (typeof selectorOrHandler === 'object' && typeof selectorOrHandler.addEventListener === 'function') {
|
|
1201
|
+
selectorOrHandler.addEventListener(event, handler);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
// 3-arg string: delegated
|
|
834
1205
|
document.addEventListener(event, (e) => {
|
|
1206
|
+
if (!e.target || typeof e.target.closest !== 'function') return;
|
|
835
1207
|
const target = e.target.closest(selectorOrHandler);
|
|
836
1208
|
if (target) handler.call(target, e);
|
|
837
1209
|
});
|
|
@@ -845,7 +1217,7 @@ query.off = (event, handler) => {
|
|
|
845
1217
|
// Extend collection prototype (like $.fn in jQuery)
|
|
846
1218
|
query.fn = ZQueryCollection.prototype;
|
|
847
1219
|
|
|
848
|
-
// --- src/expression.js
|
|
1220
|
+
// --- src/expression.js -------------------------------------------
|
|
849
1221
|
/**
|
|
850
1222
|
* zQuery Expression Parser — CSP-safe expression evaluator
|
|
851
1223
|
*
|
|
@@ -1637,13 +2009,43 @@ function _evalBinary(node, scope) {
|
|
|
1637
2009
|
* Typical: [loopVars, state, { props, refs, $ }]
|
|
1638
2010
|
* @returns {*} — evaluation result, or undefined on error
|
|
1639
2011
|
*/
|
|
2012
|
+
|
|
2013
|
+
// AST cache — avoids re-tokenizing and re-parsing the same expression string.
|
|
2014
|
+
// Bounded to prevent unbounded memory growth in long-lived apps.
|
|
2015
|
+
const _astCache = new Map();
|
|
2016
|
+
const _AST_CACHE_MAX = 512;
|
|
2017
|
+
|
|
1640
2018
|
function safeEval(expr, scope) {
|
|
1641
2019
|
try {
|
|
1642
2020
|
const trimmed = expr.trim();
|
|
1643
2021
|
if (!trimmed) return undefined;
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
2022
|
+
|
|
2023
|
+
// Fast path for simple identifiers: "count", "name", "visible"
|
|
2024
|
+
// Avoids full tokenize→parse→evaluate overhead for the most common case.
|
|
2025
|
+
if (/^[a-zA-Z_$][\w$]*$/.test(trimmed)) {
|
|
2026
|
+
for (const layer of scope) {
|
|
2027
|
+
if (layer && typeof layer === 'object' && trimmed in layer) {
|
|
2028
|
+
return layer[trimmed];
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
// Fall through to full parser for built-in globals (Math, JSON, etc.)
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// Check AST cache
|
|
2035
|
+
let ast = _astCache.get(trimmed);
|
|
2036
|
+
if (!ast) {
|
|
2037
|
+
const tokens = tokenize(trimmed);
|
|
2038
|
+
const parser = new Parser(tokens, scope);
|
|
2039
|
+
ast = parser.parse();
|
|
2040
|
+
|
|
2041
|
+
// Evict oldest entries when cache is full
|
|
2042
|
+
if (_astCache.size >= _AST_CACHE_MAX) {
|
|
2043
|
+
const first = _astCache.keys().next().value;
|
|
2044
|
+
_astCache.delete(first);
|
|
2045
|
+
}
|
|
2046
|
+
_astCache.set(trimmed, ast);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
1647
2049
|
return evaluate(ast, scope);
|
|
1648
2050
|
} catch (err) {
|
|
1649
2051
|
if (typeof console !== 'undefined' && console.debug) {
|
|
@@ -1653,7 +2055,7 @@ function safeEval(expr, scope) {
|
|
|
1653
2055
|
}
|
|
1654
2056
|
}
|
|
1655
2057
|
|
|
1656
|
-
// --- src/diff.js
|
|
2058
|
+
// --- src/diff.js -------------------------------------------------
|
|
1657
2059
|
/**
|
|
1658
2060
|
* zQuery Diff — Lightweight DOM morphing engine
|
|
1659
2061
|
*
|
|
@@ -1663,8 +2065,27 @@ function safeEval(expr, scope) {
|
|
|
1663
2065
|
*
|
|
1664
2066
|
* Approach: walk old and new trees in parallel, reconcile node by node.
|
|
1665
2067
|
* Keyed elements (via `z-key`) get matched across position changes.
|
|
2068
|
+
*
|
|
2069
|
+
* Performance advantages over virtual DOM (React/Angular):
|
|
2070
|
+
* - No virtual tree allocation or diffing — works directly on real DOM
|
|
2071
|
+
* - Skips unchanged subtrees via fast isEqualNode() check
|
|
2072
|
+
* - z-skip attribute to opt out of diffing entire subtrees
|
|
2073
|
+
* - Reuses a single template element for HTML parsing (zero GC pressure)
|
|
2074
|
+
* - Keyed reconciliation uses LIS (Longest Increasing Subsequence) to
|
|
2075
|
+
* minimize DOM moves — same algorithm as Vue 3 / ivi
|
|
2076
|
+
* - Minimal attribute diffing with early bail-out
|
|
1666
2077
|
*/
|
|
1667
2078
|
|
|
2079
|
+
// ---------------------------------------------------------------------------
|
|
2080
|
+
// Reusable template element — avoids per-call allocation
|
|
2081
|
+
// ---------------------------------------------------------------------------
|
|
2082
|
+
let _tpl = null;
|
|
2083
|
+
|
|
2084
|
+
function _getTemplate() {
|
|
2085
|
+
if (!_tpl) _tpl = document.createElement('template');
|
|
2086
|
+
return _tpl;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
1668
2089
|
// ---------------------------------------------------------------------------
|
|
1669
2090
|
// morph(existingRoot, newHTML) — patch existing DOM to match newHTML
|
|
1670
2091
|
// ---------------------------------------------------------------------------
|
|
@@ -1677,15 +2098,53 @@ function safeEval(expr, scope) {
|
|
|
1677
2098
|
* @param {string} newHTML — The desired HTML string
|
|
1678
2099
|
*/
|
|
1679
2100
|
function morph(rootEl, newHTML) {
|
|
1680
|
-
const
|
|
1681
|
-
|
|
1682
|
-
|
|
2101
|
+
const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
|
|
2102
|
+
const tpl = _getTemplate();
|
|
2103
|
+
tpl.innerHTML = newHTML;
|
|
2104
|
+
const newRoot = tpl.content;
|
|
1683
2105
|
|
|
1684
|
-
//
|
|
2106
|
+
// Move children into a wrapper for consistent handling.
|
|
2107
|
+
// We move (not clone) from the template — cheaper than cloning.
|
|
1685
2108
|
const tempDiv = document.createElement('div');
|
|
1686
2109
|
while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
|
|
1687
2110
|
|
|
1688
2111
|
_morphChildren(rootEl, tempDiv);
|
|
2112
|
+
|
|
2113
|
+
if (start) window.__zqMorphHook(rootEl, performance.now() - start);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/**
|
|
2117
|
+
* Morph a single element in place — diffs attributes and children
|
|
2118
|
+
* without replacing the node reference. Useful for replaceWith-style
|
|
2119
|
+
* updates where you want to keep the element identity when the tag
|
|
2120
|
+
* name matches.
|
|
2121
|
+
*
|
|
2122
|
+
* If the new HTML produces a different tag, falls back to native replace.
|
|
2123
|
+
*
|
|
2124
|
+
* @param {Element} oldEl — The live DOM element to patch
|
|
2125
|
+
* @param {string} newHTML — HTML string for the replacement element
|
|
2126
|
+
* @returns {Element} — The resulting element (same ref if morphed, new if replaced)
|
|
2127
|
+
*/
|
|
2128
|
+
function morphElement(oldEl, newHTML) {
|
|
2129
|
+
const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
|
|
2130
|
+
const tpl = _getTemplate();
|
|
2131
|
+
tpl.innerHTML = newHTML;
|
|
2132
|
+
const newEl = tpl.content.firstElementChild;
|
|
2133
|
+
if (!newEl) return oldEl;
|
|
2134
|
+
|
|
2135
|
+
// Same tag — morph in place (preserves identity, event listeners, refs)
|
|
2136
|
+
if (oldEl.nodeName === newEl.nodeName) {
|
|
2137
|
+
_morphAttributes(oldEl, newEl);
|
|
2138
|
+
_morphChildren(oldEl, newEl);
|
|
2139
|
+
if (start) window.__zqMorphHook(oldEl, performance.now() - start);
|
|
2140
|
+
return oldEl;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// Different tag — must replace (can't morph <div> into <span>)
|
|
2144
|
+
const clone = newEl.cloneNode(true);
|
|
2145
|
+
oldEl.parentNode.replaceChild(clone, oldEl);
|
|
2146
|
+
if (start) window.__zqMorphHook(clone, performance.now() - start);
|
|
2147
|
+
return clone;
|
|
1689
2148
|
}
|
|
1690
2149
|
|
|
1691
2150
|
/**
|
|
@@ -1695,25 +2154,42 @@ function morph(rootEl, newHTML) {
|
|
|
1695
2154
|
* @param {Element} newParent — desired state parent
|
|
1696
2155
|
*/
|
|
1697
2156
|
function _morphChildren(oldParent, newParent) {
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
const
|
|
1703
|
-
const
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
2157
|
+
// Snapshot live NodeLists into arrays — childNodes is live and
|
|
2158
|
+
// mutates during insertBefore/removeChild. Using a for loop to push
|
|
2159
|
+
// avoids spread operator overhead for large child lists.
|
|
2160
|
+
const oldCN = oldParent.childNodes;
|
|
2161
|
+
const newCN = newParent.childNodes;
|
|
2162
|
+
const oldLen = oldCN.length;
|
|
2163
|
+
const newLen = newCN.length;
|
|
2164
|
+
const oldChildren = new Array(oldLen);
|
|
2165
|
+
const newChildren = new Array(newLen);
|
|
2166
|
+
for (let i = 0; i < oldLen; i++) oldChildren[i] = oldCN[i];
|
|
2167
|
+
for (let i = 0; i < newLen; i++) newChildren[i] = newCN[i];
|
|
2168
|
+
|
|
2169
|
+
// Scan for keyed elements — only build maps if keys exist
|
|
2170
|
+
let hasKeys = false;
|
|
2171
|
+
let oldKeyMap, newKeyMap;
|
|
2172
|
+
|
|
2173
|
+
for (let i = 0; i < oldLen; i++) {
|
|
2174
|
+
if (_getKey(oldChildren[i]) != null) { hasKeys = true; break; }
|
|
2175
|
+
}
|
|
2176
|
+
if (!hasKeys) {
|
|
2177
|
+
for (let i = 0; i < newLen; i++) {
|
|
2178
|
+
if (_getKey(newChildren[i]) != null) { hasKeys = true; break; }
|
|
2179
|
+
}
|
|
1712
2180
|
}
|
|
1713
2181
|
|
|
1714
|
-
const hasKeys = oldKeyMap.size > 0 || newKeyMap.size > 0;
|
|
1715
|
-
|
|
1716
2182
|
if (hasKeys) {
|
|
2183
|
+
oldKeyMap = new Map();
|
|
2184
|
+
newKeyMap = new Map();
|
|
2185
|
+
for (let i = 0; i < oldLen; i++) {
|
|
2186
|
+
const key = _getKey(oldChildren[i]);
|
|
2187
|
+
if (key != null) oldKeyMap.set(key, i);
|
|
2188
|
+
}
|
|
2189
|
+
for (let i = 0; i < newLen; i++) {
|
|
2190
|
+
const key = _getKey(newChildren[i]);
|
|
2191
|
+
if (key != null) newKeyMap.set(key, i);
|
|
2192
|
+
}
|
|
1717
2193
|
_morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
|
|
1718
2194
|
} else {
|
|
1719
2195
|
_morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
|
|
@@ -1724,35 +2200,42 @@ function _morphChildren(oldParent, newParent) {
|
|
|
1724
2200
|
* Unkeyed reconciliation — positional matching.
|
|
1725
2201
|
*/
|
|
1726
2202
|
function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
|
|
1727
|
-
const
|
|
2203
|
+
const oldLen = oldChildren.length;
|
|
2204
|
+
const newLen = newChildren.length;
|
|
2205
|
+
const minLen = oldLen < newLen ? oldLen : newLen;
|
|
1728
2206
|
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
2207
|
+
// Morph overlapping range
|
|
2208
|
+
for (let i = 0; i < minLen; i++) {
|
|
2209
|
+
_morphNode(oldParent, oldChildren[i], newChildren[i]);
|
|
2210
|
+
}
|
|
1732
2211
|
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
2212
|
+
// Append new nodes
|
|
2213
|
+
if (newLen > oldLen) {
|
|
2214
|
+
for (let i = oldLen; i < newLen; i++) {
|
|
2215
|
+
oldParent.appendChild(newChildren[i].cloneNode(true));
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// Remove excess old nodes (iterate backwards to avoid index shifting)
|
|
2220
|
+
if (oldLen > newLen) {
|
|
2221
|
+
for (let i = oldLen - 1; i >= newLen; i--) {
|
|
2222
|
+
oldParent.removeChild(oldChildren[i]);
|
|
1741
2223
|
}
|
|
1742
2224
|
}
|
|
1743
2225
|
}
|
|
1744
2226
|
|
|
1745
2227
|
/**
|
|
1746
|
-
* Keyed reconciliation — match by z-key, reorder minimal moves
|
|
2228
|
+
* Keyed reconciliation — match by z-key, reorder with minimal moves
|
|
2229
|
+
* using Longest Increasing Subsequence (LIS) to find the maximum set
|
|
2230
|
+
* of nodes that are already in the correct relative order, then only
|
|
2231
|
+
* move the remaining nodes.
|
|
1747
2232
|
*/
|
|
1748
2233
|
function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
|
|
1749
|
-
// Track which old nodes are consumed
|
|
1750
2234
|
const consumed = new Set();
|
|
1751
|
-
|
|
1752
|
-
// Step 1: Build ordered list of matched old nodes for new children
|
|
1753
2235
|
const newLen = newChildren.length;
|
|
1754
|
-
const matched = new Array(newLen);
|
|
2236
|
+
const matched = new Array(newLen);
|
|
1755
2237
|
|
|
2238
|
+
// Step 1: Match new children to old children by key
|
|
1756
2239
|
for (let i = 0; i < newLen; i++) {
|
|
1757
2240
|
const key = _getKey(newChildren[i]);
|
|
1758
2241
|
if (key != null && oldKeyMap.has(key)) {
|
|
@@ -1764,21 +2247,40 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
1764
2247
|
}
|
|
1765
2248
|
}
|
|
1766
2249
|
|
|
1767
|
-
// Step 2: Remove old nodes
|
|
2250
|
+
// Step 2: Remove old keyed nodes not in the new tree
|
|
1768
2251
|
for (let i = oldChildren.length - 1; i >= 0; i--) {
|
|
1769
2252
|
if (!consumed.has(i)) {
|
|
1770
2253
|
const key = _getKey(oldChildren[i]);
|
|
1771
2254
|
if (key != null && !newKeyMap.has(key)) {
|
|
1772
2255
|
oldParent.removeChild(oldChildren[i]);
|
|
1773
|
-
} else if (key == null) {
|
|
1774
|
-
// Unkeyed old node — will be handled positionally below
|
|
1775
2256
|
}
|
|
1776
2257
|
}
|
|
1777
2258
|
}
|
|
1778
2259
|
|
|
1779
|
-
// Step 3:
|
|
2260
|
+
// Step 3: Build index array for LIS of matched old indices.
|
|
2261
|
+
// This finds the largest set of keyed nodes already in order,
|
|
2262
|
+
// so we only need to move the rest — O(n log n) instead of O(n²).
|
|
2263
|
+
const oldIndices = []; // Maps new-position → old-position (or -1)
|
|
2264
|
+
for (let i = 0; i < newLen; i++) {
|
|
2265
|
+
if (matched[i]) {
|
|
2266
|
+
const key = _getKey(newChildren[i]);
|
|
2267
|
+
oldIndices.push(oldKeyMap.get(key));
|
|
2268
|
+
} else {
|
|
2269
|
+
oldIndices.push(-1);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
const lisSet = _lis(oldIndices);
|
|
2274
|
+
|
|
2275
|
+
// Step 4: Insert / reorder / morph — walk new children forward,
|
|
2276
|
+
// using LIS to decide which nodes stay in place.
|
|
1780
2277
|
let cursor = oldParent.firstChild;
|
|
1781
|
-
const unkeyedOld =
|
|
2278
|
+
const unkeyedOld = [];
|
|
2279
|
+
for (let i = 0; i < oldChildren.length; i++) {
|
|
2280
|
+
if (!consumed.has(i) && _getKey(oldChildren[i]) == null) {
|
|
2281
|
+
unkeyedOld.push(oldChildren[i]);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
1782
2284
|
let unkeyedIdx = 0;
|
|
1783
2285
|
|
|
1784
2286
|
for (let i = 0; i < newLen; i++) {
|
|
@@ -1787,16 +2289,14 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
1787
2289
|
let oldNode = matched[i];
|
|
1788
2290
|
|
|
1789
2291
|
if (!oldNode && newKey == null) {
|
|
1790
|
-
// Try to match an unkeyed old node positionally
|
|
1791
2292
|
oldNode = unkeyedOld[unkeyedIdx++] || null;
|
|
1792
2293
|
}
|
|
1793
2294
|
|
|
1794
2295
|
if (oldNode) {
|
|
1795
|
-
//
|
|
1796
|
-
if (
|
|
2296
|
+
// If this node is NOT part of the LIS, it needs to be moved
|
|
2297
|
+
if (!lisSet.has(i)) {
|
|
1797
2298
|
oldParent.insertBefore(oldNode, cursor);
|
|
1798
2299
|
}
|
|
1799
|
-
// Morph in place
|
|
1800
2300
|
_morphNode(oldParent, oldNode, newNode);
|
|
1801
2301
|
cursor = oldNode.nextSibling;
|
|
1802
2302
|
} else {
|
|
@@ -1807,11 +2307,10 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
1807
2307
|
} else {
|
|
1808
2308
|
oldParent.appendChild(clone);
|
|
1809
2309
|
}
|
|
1810
|
-
// cursor stays the same — new node is before it
|
|
1811
2310
|
}
|
|
1812
2311
|
}
|
|
1813
2312
|
|
|
1814
|
-
// Remove
|
|
2313
|
+
// Remove remaining unkeyed old nodes
|
|
1815
2314
|
while (unkeyedIdx < unkeyedOld.length) {
|
|
1816
2315
|
const leftover = unkeyedOld[unkeyedIdx++];
|
|
1817
2316
|
if (leftover.parentNode === oldParent) {
|
|
@@ -1830,6 +2329,54 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
1830
2329
|
}
|
|
1831
2330
|
}
|
|
1832
2331
|
|
|
2332
|
+
/**
|
|
2333
|
+
* Compute the Longest Increasing Subsequence of an index array.
|
|
2334
|
+
* Returns a Set of positions (in the input) that form the LIS.
|
|
2335
|
+
* Entries with value -1 (unmatched) are excluded.
|
|
2336
|
+
*
|
|
2337
|
+
* O(n log n) — same algorithm used by Vue 3 and ivi.
|
|
2338
|
+
*
|
|
2339
|
+
* @param {number[]} arr — array of old-tree indices (-1 = unmatched)
|
|
2340
|
+
* @returns {Set<number>} — positions in arr belonging to the LIS
|
|
2341
|
+
*/
|
|
2342
|
+
function _lis(arr) {
|
|
2343
|
+
const len = arr.length;
|
|
2344
|
+
const result = new Set();
|
|
2345
|
+
if (len === 0) return result;
|
|
2346
|
+
|
|
2347
|
+
// tails[i] = index in arr of the smallest tail element for LIS of length i+1
|
|
2348
|
+
const tails = [];
|
|
2349
|
+
// prev[i] = predecessor index in arr for the LIS ending at arr[i]
|
|
2350
|
+
const prev = new Array(len).fill(-1);
|
|
2351
|
+
const tailIndices = []; // parallel to tails: actual positions
|
|
2352
|
+
|
|
2353
|
+
for (let i = 0; i < len; i++) {
|
|
2354
|
+
if (arr[i] === -1) continue;
|
|
2355
|
+
const val = arr[i];
|
|
2356
|
+
|
|
2357
|
+
// Binary search for insertion point in tails
|
|
2358
|
+
let lo = 0, hi = tails.length;
|
|
2359
|
+
while (lo < hi) {
|
|
2360
|
+
const mid = (lo + hi) >> 1;
|
|
2361
|
+
if (tails[mid] < val) lo = mid + 1;
|
|
2362
|
+
else hi = mid;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
tails[lo] = val;
|
|
2366
|
+
tailIndices[lo] = i;
|
|
2367
|
+
prev[i] = lo > 0 ? tailIndices[lo - 1] : -1;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// Reconstruct: walk backwards from the last element of LIS
|
|
2371
|
+
let k = tailIndices[tails.length - 1];
|
|
2372
|
+
for (let i = tails.length - 1; i >= 0; i--) {
|
|
2373
|
+
result.add(k);
|
|
2374
|
+
k = prev[k];
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
return result;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
1833
2380
|
/**
|
|
1834
2381
|
* Morph a single node in place.
|
|
1835
2382
|
*/
|
|
@@ -1856,10 +2403,18 @@ function _morphNode(parent, oldNode, newNode) {
|
|
|
1856
2403
|
|
|
1857
2404
|
// Both are elements — diff attributes then recurse children
|
|
1858
2405
|
if (oldNode.nodeType === 1) {
|
|
2406
|
+
// z-skip: developer opt-out — skip diffing this subtree entirely.
|
|
2407
|
+
// Useful for third-party widgets, canvas, video, or large static content.
|
|
2408
|
+
if (oldNode.hasAttribute('z-skip')) return;
|
|
2409
|
+
|
|
2410
|
+
// Fast bail-out: if the elements are identical, skip everything.
|
|
2411
|
+
// isEqualNode() is a native C++ comparison — much faster than walking
|
|
2412
|
+
// attributes + children in JS when trees haven't changed.
|
|
2413
|
+
if (oldNode.isEqualNode(newNode)) return;
|
|
2414
|
+
|
|
1859
2415
|
_morphAttributes(oldNode, newNode);
|
|
1860
2416
|
|
|
1861
2417
|
// Special elements: don't recurse into their children
|
|
1862
|
-
// (textarea value, input value, select, etc.)
|
|
1863
2418
|
const tag = oldNode.nodeName;
|
|
1864
2419
|
if (tag === 'INPUT') {
|
|
1865
2420
|
_syncInputValue(oldNode, newNode);
|
|
@@ -1872,7 +2427,6 @@ function _morphNode(parent, oldNode, newNode) {
|
|
|
1872
2427
|
return;
|
|
1873
2428
|
}
|
|
1874
2429
|
if (tag === 'SELECT') {
|
|
1875
|
-
// Recurse children (options) then sync value
|
|
1876
2430
|
_morphChildren(oldNode, newNode);
|
|
1877
2431
|
if (oldNode.value !== newNode.value) {
|
|
1878
2432
|
oldNode.value = newNode.value;
|
|
@@ -1887,23 +2441,45 @@ function _morphNode(parent, oldNode, newNode) {
|
|
|
1887
2441
|
|
|
1888
2442
|
/**
|
|
1889
2443
|
* Sync attributes from newEl onto oldEl.
|
|
2444
|
+
* Uses a single pass: build a set of new attribute names, iterate
|
|
2445
|
+
* old attrs for removals, then apply new attrs.
|
|
1890
2446
|
*/
|
|
1891
2447
|
function _morphAttributes(oldEl, newEl) {
|
|
1892
|
-
// Add/update attributes
|
|
1893
2448
|
const newAttrs = newEl.attributes;
|
|
1894
|
-
|
|
2449
|
+
const oldAttrs = oldEl.attributes;
|
|
2450
|
+
const newLen = newAttrs.length;
|
|
2451
|
+
const oldLen = oldAttrs.length;
|
|
2452
|
+
|
|
2453
|
+
// Fast path: if both have same number of attributes, check if they're identical
|
|
2454
|
+
if (newLen === oldLen) {
|
|
2455
|
+
let same = true;
|
|
2456
|
+
for (let i = 0; i < newLen; i++) {
|
|
2457
|
+
const na = newAttrs[i];
|
|
2458
|
+
if (oldEl.getAttribute(na.name) !== na.value) { same = false; break; }
|
|
2459
|
+
}
|
|
2460
|
+
if (same) {
|
|
2461
|
+
// Also verify no extra old attrs (names mismatch)
|
|
2462
|
+
for (let i = 0; i < oldLen; i++) {
|
|
2463
|
+
if (!newEl.hasAttribute(oldAttrs[i].name)) { same = false; break; }
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
if (same) return;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
// Build set of new attr names for O(1) lookup during removal pass
|
|
2470
|
+
const newNames = new Set();
|
|
2471
|
+
for (let i = 0; i < newLen; i++) {
|
|
1895
2472
|
const attr = newAttrs[i];
|
|
2473
|
+
newNames.add(attr.name);
|
|
1896
2474
|
if (oldEl.getAttribute(attr.name) !== attr.value) {
|
|
1897
2475
|
oldEl.setAttribute(attr.name, attr.value);
|
|
1898
2476
|
}
|
|
1899
2477
|
}
|
|
1900
2478
|
|
|
1901
2479
|
// Remove stale attributes
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
if (!newEl.hasAttribute(attr.name)) {
|
|
1906
|
-
oldEl.removeAttribute(attr.name);
|
|
2480
|
+
for (let i = oldLen - 1; i >= 0; i--) {
|
|
2481
|
+
if (!newNames.has(oldAttrs[i].name)) {
|
|
2482
|
+
oldEl.removeAttribute(oldAttrs[i].name);
|
|
1907
2483
|
}
|
|
1908
2484
|
}
|
|
1909
2485
|
}
|
|
@@ -1927,15 +2503,39 @@ function _syncInputValue(oldEl, newEl) {
|
|
|
1927
2503
|
}
|
|
1928
2504
|
|
|
1929
2505
|
/**
|
|
1930
|
-
* Get the reconciliation key from a node
|
|
2506
|
+
* Get the reconciliation key from a node.
|
|
2507
|
+
*
|
|
2508
|
+
* Priority: z-key attribute → id attribute → data-id / data-key.
|
|
2509
|
+
* Auto-detected keys use a `\0` prefix to avoid collisions with
|
|
2510
|
+
* explicit z-key values.
|
|
2511
|
+
*
|
|
2512
|
+
* This means the LIS-optimised keyed path activates automatically
|
|
2513
|
+
* whenever elements carry `id` or `data-id` / `data-key` attributes
|
|
2514
|
+
* — no extra markup required.
|
|
2515
|
+
*
|
|
1931
2516
|
* @returns {string|null}
|
|
1932
2517
|
*/
|
|
1933
2518
|
function _getKey(node) {
|
|
1934
2519
|
if (node.nodeType !== 1) return null;
|
|
1935
|
-
|
|
2520
|
+
|
|
2521
|
+
// Explicit z-key — highest priority
|
|
2522
|
+
const zk = node.getAttribute('z-key');
|
|
2523
|
+
if (zk) return zk;
|
|
2524
|
+
|
|
2525
|
+
// Auto-key: id attribute (unique by spec)
|
|
2526
|
+
if (node.id) return '\0id:' + node.id;
|
|
2527
|
+
|
|
2528
|
+
// Auto-key: data-id or data-key attributes
|
|
2529
|
+
const ds = node.dataset;
|
|
2530
|
+
if (ds) {
|
|
2531
|
+
if (ds.id) return '\0data-id:' + ds.id;
|
|
2532
|
+
if (ds.key) return '\0data-key:' + ds.key;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
return null;
|
|
1936
2536
|
}
|
|
1937
2537
|
|
|
1938
|
-
// --- src/component.js
|
|
2538
|
+
// --- src/component.js --------------------------------------------
|
|
1939
2539
|
/**
|
|
1940
2540
|
* zQuery Component — Lightweight reactive component system
|
|
1941
2541
|
*
|
|
@@ -2276,7 +2876,7 @@ class Component {
|
|
|
2276
2876
|
// Normalize items → [{id, label}, …]
|
|
2277
2877
|
def._pages = (p.items || []).map(item => {
|
|
2278
2878
|
if (typeof item === 'string') return { id: item, label: _titleCase(item) };
|
|
2279
|
-
return {
|
|
2879
|
+
return { ...item, label: item.label || _titleCase(item.id) };
|
|
2280
2880
|
});
|
|
2281
2881
|
|
|
2282
2882
|
// Build URL map for lazy per-page loading.
|
|
@@ -2318,11 +2918,14 @@ class Component {
|
|
|
2318
2918
|
if (def.styleUrl && !def._styleLoaded) {
|
|
2319
2919
|
const su = def.styleUrl;
|
|
2320
2920
|
if (typeof su === 'string') {
|
|
2321
|
-
|
|
2921
|
+
const resolved = _resolveUrl(su, base);
|
|
2922
|
+
def._externalStyles = await _fetchResource(resolved);
|
|
2923
|
+
def._resolvedStyleUrls = [resolved];
|
|
2322
2924
|
} else if (Array.isArray(su)) {
|
|
2323
2925
|
const urls = su.map(u => _resolveUrl(u, base));
|
|
2324
2926
|
const results = await Promise.all(urls.map(u => _fetchResource(u)));
|
|
2325
2927
|
def._externalStyles = results.join('\n');
|
|
2928
|
+
def._resolvedStyleUrls = urls;
|
|
2326
2929
|
}
|
|
2327
2930
|
def._styleLoaded = true;
|
|
2328
2931
|
}
|
|
@@ -2406,6 +3009,11 @@ class Component {
|
|
|
2406
3009
|
html = '';
|
|
2407
3010
|
}
|
|
2408
3011
|
|
|
3012
|
+
// Pre-expand z-html and z-text at string level so the morph engine
|
|
3013
|
+
// can diff their content properly (instead of clearing + re-injecting
|
|
3014
|
+
// on every re-render). Same pattern as z-for: parse → evaluate → serialize.
|
|
3015
|
+
html = this._expandContentDirectives(html);
|
|
3016
|
+
|
|
2409
3017
|
// -- Slot distribution ----------------------------------------
|
|
2410
3018
|
// Replace <slot> elements with captured slot content from parent.
|
|
2411
3019
|
// <slot> → default slot content
|
|
@@ -2449,6 +3057,13 @@ class Component {
|
|
|
2449
3057
|
const styleEl = document.createElement('style');
|
|
2450
3058
|
styleEl.textContent = scoped;
|
|
2451
3059
|
styleEl.setAttribute('data-zq-component', this._def._name || '');
|
|
3060
|
+
styleEl.setAttribute('data-zq-scope', scopeAttr);
|
|
3061
|
+
if (this._def._resolvedStyleUrls) {
|
|
3062
|
+
styleEl.setAttribute('data-zq-style-urls', this._def._resolvedStyleUrls.join(' '));
|
|
3063
|
+
if (this._def.styles) {
|
|
3064
|
+
styleEl.setAttribute('data-zq-inline', this._def.styles);
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
2452
3067
|
document.head.appendChild(styleEl);
|
|
2453
3068
|
this._styleEl = styleEl;
|
|
2454
3069
|
}
|
|
@@ -2488,8 +3103,10 @@ class Component {
|
|
|
2488
3103
|
|
|
2489
3104
|
// Update DOM via morphing (diffing) — preserves unchanged nodes
|
|
2490
3105
|
// First render uses innerHTML for speed; subsequent renders morph.
|
|
3106
|
+
const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
|
|
2491
3107
|
if (!this._mounted) {
|
|
2492
3108
|
this._el.innerHTML = html;
|
|
3109
|
+
if (_renderStart && window.__zqRenderHook) window.__zqRenderHook(this._el, performance.now() - _renderStart, 'mount', this._def._name);
|
|
2493
3110
|
} else {
|
|
2494
3111
|
morph(this._el, html);
|
|
2495
3112
|
}
|
|
@@ -2532,31 +3149,31 @@ class Component {
|
|
|
2532
3149
|
}
|
|
2533
3150
|
}
|
|
2534
3151
|
|
|
2535
|
-
// Bind @event="method" and z-on:event="method" handlers via delegation
|
|
3152
|
+
// Bind @event="method" and z-on:event="method" handlers via delegation.
|
|
3153
|
+
//
|
|
3154
|
+
// Optimization: on the FIRST render, we scan for event attributes, build
|
|
3155
|
+
// a delegated handler map, and attach one listener per event type to the
|
|
3156
|
+
// component root. On subsequent renders (re-bind), we only rebuild the
|
|
3157
|
+
// internal binding map — existing DOM listeners are reused since they
|
|
3158
|
+
// delegate to event.target.closest(selector) at fire time.
|
|
2536
3159
|
_bindEvents() {
|
|
2537
|
-
//
|
|
2538
|
-
|
|
2539
|
-
this._el.removeEventListener(event, handler);
|
|
2540
|
-
});
|
|
2541
|
-
this._listeners = [];
|
|
3160
|
+
// Always rebuild the binding map from current DOM
|
|
3161
|
+
const eventMap = new Map(); // event → [{ selector, methodExpr, modifiers, el }]
|
|
2542
3162
|
|
|
2543
|
-
// Find all elements with @event or z-on:event attributes
|
|
2544
3163
|
const allEls = this._el.querySelectorAll('*');
|
|
2545
|
-
const eventMap = new Map(); // event → [{ selector, method, modifiers }]
|
|
2546
|
-
|
|
2547
3164
|
allEls.forEach(child => {
|
|
2548
|
-
// Skip elements inside z-pre subtrees
|
|
2549
3165
|
if (child.closest('[z-pre]')) return;
|
|
2550
3166
|
|
|
2551
|
-
|
|
2552
|
-
|
|
3167
|
+
const attrs = child.attributes;
|
|
3168
|
+
for (let a = 0; a < attrs.length; a++) {
|
|
3169
|
+
const attr = attrs[a];
|
|
2553
3170
|
let raw;
|
|
2554
|
-
if (attr.name.
|
|
2555
|
-
raw = attr.name.slice(1);
|
|
3171
|
+
if (attr.name.charCodeAt(0) === 64) { // '@'
|
|
3172
|
+
raw = attr.name.slice(1);
|
|
2556
3173
|
} else if (attr.name.startsWith('z-on:')) {
|
|
2557
|
-
raw = attr.name.slice(5);
|
|
3174
|
+
raw = attr.name.slice(5);
|
|
2558
3175
|
} else {
|
|
2559
|
-
|
|
3176
|
+
continue;
|
|
2560
3177
|
}
|
|
2561
3178
|
|
|
2562
3179
|
const parts = raw.split('.');
|
|
@@ -2572,12 +3189,45 @@ class Component {
|
|
|
2572
3189
|
|
|
2573
3190
|
if (!eventMap.has(event)) eventMap.set(event, []);
|
|
2574
3191
|
eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
|
|
2575
|
-
}
|
|
3192
|
+
}
|
|
2576
3193
|
});
|
|
2577
3194
|
|
|
3195
|
+
// Store binding map for the delegated handlers to reference
|
|
3196
|
+
this._eventBindings = eventMap;
|
|
3197
|
+
|
|
3198
|
+
// Only attach DOM listeners once — reuse on subsequent renders.
|
|
3199
|
+
// The handlers close over `this` and read `this._eventBindings`
|
|
3200
|
+
// at fire time, so they always use the latest binding map.
|
|
3201
|
+
if (this._delegatedEvents) {
|
|
3202
|
+
// Already attached — just make sure new event types are covered
|
|
3203
|
+
for (const event of eventMap.keys()) {
|
|
3204
|
+
if (!this._delegatedEvents.has(event)) {
|
|
3205
|
+
this._attachDelegatedEvent(event, eventMap.get(event));
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
// Remove listeners for event types no longer in the template
|
|
3209
|
+
for (const event of this._delegatedEvents.keys()) {
|
|
3210
|
+
if (!eventMap.has(event)) {
|
|
3211
|
+
const { handler, opts } = this._delegatedEvents.get(event);
|
|
3212
|
+
this._el.removeEventListener(event, handler, opts);
|
|
3213
|
+
this._delegatedEvents.delete(event);
|
|
3214
|
+
// Also remove from _listeners array
|
|
3215
|
+
this._listeners = this._listeners.filter(l => l.event !== event);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
this._delegatedEvents = new Map();
|
|
3222
|
+
|
|
2578
3223
|
// Register delegated listeners on the component root
|
|
2579
3224
|
for (const [event, bindings] of eventMap) {
|
|
2580
|
-
|
|
3225
|
+
this._attachDelegatedEvent(event, bindings);
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
// Attach a single delegated listener for an event type
|
|
3230
|
+
_attachDelegatedEvent(event, bindings) {
|
|
2581
3231
|
const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
|
|
2582
3232
|
const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
|
|
2583
3233
|
const listenerOpts = (needsCapture || needsPassive)
|
|
@@ -2585,7 +3235,9 @@ class Component {
|
|
|
2585
3235
|
: false;
|
|
2586
3236
|
|
|
2587
3237
|
const handler = (e) => {
|
|
2588
|
-
|
|
3238
|
+
// Read bindings from live map — always up to date after re-renders
|
|
3239
|
+
const currentBindings = this._eventBindings?.get(event) || [];
|
|
3240
|
+
for (const { selector, methodExpr, modifiers, el } of currentBindings) {
|
|
2589
3241
|
if (!e.target.closest(selector)) continue;
|
|
2590
3242
|
|
|
2591
3243
|
// .self — only fire if target is the element itself
|
|
@@ -2655,7 +3307,7 @@ class Component {
|
|
|
2655
3307
|
};
|
|
2656
3308
|
this._el.addEventListener(event, handler, listenerOpts);
|
|
2657
3309
|
this._listeners.push({ event, handler });
|
|
2658
|
-
|
|
3310
|
+
this._delegatedEvents.set(event, { handler, opts: listenerOpts });
|
|
2659
3311
|
}
|
|
2660
3312
|
|
|
2661
3313
|
// Bind z-ref="name" → this.refs.name
|
|
@@ -2694,7 +3346,7 @@ class Component {
|
|
|
2694
3346
|
// Read current state value (supports dot-path keys)
|
|
2695
3347
|
const currentVal = _getPath(this.state, key);
|
|
2696
3348
|
|
|
2697
|
-
// -- Set initial DOM value from state
|
|
3349
|
+
// -- Set initial DOM value from state (always sync) ----------
|
|
2698
3350
|
if (tag === 'input' && type === 'checkbox') {
|
|
2699
3351
|
el.checked = !!currentVal;
|
|
2700
3352
|
} else if (tag === 'input' && type === 'radio') {
|
|
@@ -2716,6 +3368,11 @@ class Component {
|
|
|
2716
3368
|
: isEditable ? 'input' : 'input';
|
|
2717
3369
|
|
|
2718
3370
|
// -- Handler: read DOM → write to reactive state -------------
|
|
3371
|
+
// Skip if already bound (morph preserves existing elements,
|
|
3372
|
+
// so re-binding would stack duplicate listeners)
|
|
3373
|
+
if (el._zqModelBound) return;
|
|
3374
|
+
el._zqModelBound = true;
|
|
3375
|
+
|
|
2719
3376
|
const handler = () => {
|
|
2720
3377
|
let val;
|
|
2721
3378
|
if (type === 'checkbox') val = el.checked;
|
|
@@ -2835,6 +3492,41 @@ class Component {
|
|
|
2835
3492
|
return temp.innerHTML;
|
|
2836
3493
|
}
|
|
2837
3494
|
|
|
3495
|
+
// ---------------------------------------------------------------------------
|
|
3496
|
+
// _expandContentDirectives — Pre-morph z-html & z-text expansion
|
|
3497
|
+
//
|
|
3498
|
+
// Evaluates z-html and z-text directives at the string level so the morph
|
|
3499
|
+
// engine receives HTML with the actual content inline. This lets the diff
|
|
3500
|
+
// algorithm properly compare old vs new content (text nodes, child elements)
|
|
3501
|
+
// instead of clearing + re-injecting on every re-render.
|
|
3502
|
+
//
|
|
3503
|
+
// Same parse → evaluate → serialize pattern as _expandZFor.
|
|
3504
|
+
// ---------------------------------------------------------------------------
|
|
3505
|
+
_expandContentDirectives(html) {
|
|
3506
|
+
if (!html.includes('z-html') && !html.includes('z-text')) return html;
|
|
3507
|
+
|
|
3508
|
+
const temp = document.createElement('div');
|
|
3509
|
+
temp.innerHTML = html;
|
|
3510
|
+
|
|
3511
|
+
// z-html: evaluate expression → inject as innerHTML
|
|
3512
|
+
temp.querySelectorAll('[z-html]').forEach(el => {
|
|
3513
|
+
if (el.closest('[z-pre]')) return;
|
|
3514
|
+
const val = this._evalExpr(el.getAttribute('z-html'));
|
|
3515
|
+
el.innerHTML = val != null ? String(val) : '';
|
|
3516
|
+
el.removeAttribute('z-html');
|
|
3517
|
+
});
|
|
3518
|
+
|
|
3519
|
+
// z-text: evaluate expression → inject as textContent (HTML-safe)
|
|
3520
|
+
temp.querySelectorAll('[z-text]').forEach(el => {
|
|
3521
|
+
if (el.closest('[z-pre]')) return;
|
|
3522
|
+
const val = this._evalExpr(el.getAttribute('z-text'));
|
|
3523
|
+
el.textContent = val != null ? String(val) : '';
|
|
3524
|
+
el.removeAttribute('z-text');
|
|
3525
|
+
});
|
|
3526
|
+
|
|
3527
|
+
return temp.innerHTML;
|
|
3528
|
+
}
|
|
3529
|
+
|
|
2838
3530
|
// ---------------------------------------------------------------------------
|
|
2839
3531
|
// _processDirectives — Post-innerHTML DOM-level directive processing
|
|
2840
3532
|
// ---------------------------------------------------------------------------
|
|
@@ -2887,25 +3579,36 @@ class Component {
|
|
|
2887
3579
|
});
|
|
2888
3580
|
|
|
2889
3581
|
// -- z-bind:attr / :attr (dynamic attribute binding) -----------
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
3582
|
+
// Use TreeWalker instead of querySelectorAll('*') — avoids
|
|
3583
|
+
// creating a flat array of every single descendant element.
|
|
3584
|
+
// TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
|
|
3585
|
+
// at the walker level (faster than per-node closest('[z-pre]') checks).
|
|
3586
|
+
const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
|
|
3587
|
+
acceptNode(n) {
|
|
3588
|
+
return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
|
|
3589
|
+
}
|
|
3590
|
+
});
|
|
3591
|
+
let node;
|
|
3592
|
+
while ((node = walker.nextNode())) {
|
|
3593
|
+
const attrs = node.attributes;
|
|
3594
|
+
for (let i = attrs.length - 1; i >= 0; i--) {
|
|
3595
|
+
const attr = attrs[i];
|
|
2893
3596
|
let attrName;
|
|
2894
3597
|
if (attr.name.startsWith('z-bind:')) attrName = attr.name.slice(7);
|
|
2895
|
-
else if (attr.name.
|
|
2896
|
-
else
|
|
3598
|
+
else if (attr.name.charCodeAt(0) === 58 && attr.name.charCodeAt(1) !== 58) attrName = attr.name.slice(1); // ':' but not '::'
|
|
3599
|
+
else continue;
|
|
2897
3600
|
|
|
2898
3601
|
const val = this._evalExpr(attr.value);
|
|
2899
|
-
|
|
3602
|
+
node.removeAttribute(attr.name);
|
|
2900
3603
|
if (val === false || val === null || val === undefined) {
|
|
2901
|
-
|
|
3604
|
+
node.removeAttribute(attrName);
|
|
2902
3605
|
} else if (val === true) {
|
|
2903
|
-
|
|
3606
|
+
node.setAttribute(attrName, '');
|
|
2904
3607
|
} else {
|
|
2905
|
-
|
|
3608
|
+
node.setAttribute(attrName, String(val));
|
|
2906
3609
|
}
|
|
2907
|
-
}
|
|
2908
|
-
}
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
2909
3612
|
|
|
2910
3613
|
// -- z-class (dynamic class binding) ---------------------------
|
|
2911
3614
|
this._el.querySelectorAll('[z-class]').forEach(el => {
|
|
@@ -2937,21 +3640,9 @@ class Component {
|
|
|
2937
3640
|
el.removeAttribute('z-style');
|
|
2938
3641
|
});
|
|
2939
3642
|
|
|
2940
|
-
//
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
const val = this._evalExpr(el.getAttribute('z-html'));
|
|
2944
|
-
el.innerHTML = val != null ? String(val) : '';
|
|
2945
|
-
el.removeAttribute('z-html');
|
|
2946
|
-
});
|
|
2947
|
-
|
|
2948
|
-
// -- z-text (safe textContent binding) -------------------------
|
|
2949
|
-
this._el.querySelectorAll('[z-text]').forEach(el => {
|
|
2950
|
-
if (el.closest('[z-pre]')) return;
|
|
2951
|
-
const val = this._evalExpr(el.getAttribute('z-text'));
|
|
2952
|
-
el.textContent = val != null ? String(val) : '';
|
|
2953
|
-
el.removeAttribute('z-text');
|
|
2954
|
-
});
|
|
3643
|
+
// z-html and z-text are now pre-expanded at string level (before
|
|
3644
|
+
// morph) via _expandContentDirectives(), so the diff engine can
|
|
3645
|
+
// properly diff their content instead of clearing + re-injecting.
|
|
2955
3646
|
|
|
2956
3647
|
// -- z-cloak (remove after render) -----------------------------
|
|
2957
3648
|
this._el.querySelectorAll('[z-cloak]').forEach(el => {
|
|
@@ -2984,6 +3675,8 @@ class Component {
|
|
|
2984
3675
|
}
|
|
2985
3676
|
this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
|
|
2986
3677
|
this._listeners = [];
|
|
3678
|
+
this._delegatedEvents = null;
|
|
3679
|
+
this._eventBindings = null;
|
|
2987
3680
|
if (this._styleEl) this._styleEl.remove();
|
|
2988
3681
|
_instances.delete(this._el);
|
|
2989
3682
|
this._el.innerHTML = '';
|
|
@@ -3134,6 +3827,36 @@ function getRegistry() {
|
|
|
3134
3827
|
return Object.fromEntries(_registry);
|
|
3135
3828
|
}
|
|
3136
3829
|
|
|
3830
|
+
/**
|
|
3831
|
+
* Pre-load a component's external templates and styles so the next mount
|
|
3832
|
+
* renders synchronously (no blank flash while fetching).
|
|
3833
|
+
* Safe to call multiple times — skips if already loaded.
|
|
3834
|
+
* @param {string} name — registered component name
|
|
3835
|
+
* @returns {Promise<void>}
|
|
3836
|
+
*/
|
|
3837
|
+
async function prefetch(name) {
|
|
3838
|
+
const def = _registry.get(name);
|
|
3839
|
+
if (!def) return;
|
|
3840
|
+
|
|
3841
|
+
// Load templateUrl, styleUrl, and normalize pages config
|
|
3842
|
+
if ((def.templateUrl && !def._templateLoaded) ||
|
|
3843
|
+
(def.styleUrl && !def._styleLoaded) ||
|
|
3844
|
+
(def.pages && !def._pagesNormalized)) {
|
|
3845
|
+
await Component.prototype._loadExternals.call({ _def: def });
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
// For pages-based components, prefetch ALL page templates so any
|
|
3849
|
+
// active page renders instantly on mount.
|
|
3850
|
+
if (def._pageUrls && def._externalTemplates) {
|
|
3851
|
+
const missing = Object.entries(def._pageUrls)
|
|
3852
|
+
.filter(([id]) => !(id in def._externalTemplates));
|
|
3853
|
+
if (missing.length) {
|
|
3854
|
+
const results = await Promise.all(missing.map(([, url]) => _fetchResource(url)));
|
|
3855
|
+
missing.forEach(([id], i) => { def._externalTemplates[id] = results[i]; });
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3137
3860
|
|
|
3138
3861
|
// ---------------------------------------------------------------------------
|
|
3139
3862
|
// Global stylesheet loader
|
|
@@ -3235,12 +3958,13 @@ function style(urls, opts = {}) {
|
|
|
3235
3958
|
};
|
|
3236
3959
|
}
|
|
3237
3960
|
|
|
3238
|
-
// --- src/router.js
|
|
3961
|
+
// --- src/router.js -----------------------------------------------
|
|
3239
3962
|
/**
|
|
3240
3963
|
* zQuery Router — Client-side SPA router
|
|
3241
3964
|
*
|
|
3242
3965
|
* Supports hash mode (#/path) and history mode (/path).
|
|
3243
3966
|
* Route params, query strings, navigation guards, and lazy loading.
|
|
3967
|
+
* Sub-route history substates for in-page UI changes (modals, tabs, etc.).
|
|
3244
3968
|
*
|
|
3245
3969
|
* Usage:
|
|
3246
3970
|
* $.router({
|
|
@@ -3257,6 +3981,9 @@ function style(urls, opts = {}) {
|
|
|
3257
3981
|
|
|
3258
3982
|
|
|
3259
3983
|
|
|
3984
|
+
// Unique marker on history.state to identify zQuery-managed entries
|
|
3985
|
+
const _ZQ_STATE_KEY = '__zq';
|
|
3986
|
+
|
|
3260
3987
|
class Router {
|
|
3261
3988
|
constructor(config = {}) {
|
|
3262
3989
|
this._el = null;
|
|
@@ -3290,6 +4017,10 @@ class Router {
|
|
|
3290
4017
|
this._instance = null; // current mounted component
|
|
3291
4018
|
this._resolving = false; // re-entrancy guard
|
|
3292
4019
|
|
|
4020
|
+
// Sub-route history substates
|
|
4021
|
+
this._substateListeners = []; // [(key, data) => bool|void]
|
|
4022
|
+
this._inSubstate = false; // true while substate entries are in the history stack
|
|
4023
|
+
|
|
3293
4024
|
// Set outlet element
|
|
3294
4025
|
if (config.el) {
|
|
3295
4026
|
this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
|
|
@@ -3304,7 +4035,21 @@ class Router {
|
|
|
3304
4035
|
if (this._mode === 'hash') {
|
|
3305
4036
|
window.addEventListener('hashchange', () => this._resolve());
|
|
3306
4037
|
} else {
|
|
3307
|
-
window.addEventListener('popstate', () =>
|
|
4038
|
+
window.addEventListener('popstate', (e) => {
|
|
4039
|
+
// Check for substate pop first — if a listener handles it, don't route
|
|
4040
|
+
const st = e.state;
|
|
4041
|
+
if (st && st[_ZQ_STATE_KEY] === 'substate') {
|
|
4042
|
+
const handled = this._fireSubstate(st.key, st.data, 'pop');
|
|
4043
|
+
if (handled) return;
|
|
4044
|
+
// Unhandled substate — fall through to route resolve
|
|
4045
|
+
// _inSubstate stays true so the next non-substate pop triggers reset
|
|
4046
|
+
} else if (this._inSubstate) {
|
|
4047
|
+
// Popped past all substates — notify listeners to reset to defaults
|
|
4048
|
+
this._inSubstate = false;
|
|
4049
|
+
this._fireSubstate(null, null, 'reset');
|
|
4050
|
+
}
|
|
4051
|
+
this._resolve();
|
|
4052
|
+
});
|
|
3308
4053
|
}
|
|
3309
4054
|
|
|
3310
4055
|
// Intercept link clicks for SPA navigation
|
|
@@ -3315,7 +4060,21 @@ class Router {
|
|
|
3315
4060
|
if (!link) return;
|
|
3316
4061
|
if (link.getAttribute('target') === '_blank') return;
|
|
3317
4062
|
e.preventDefault();
|
|
3318
|
-
|
|
4063
|
+
let href = link.getAttribute('z-link');
|
|
4064
|
+
// Support z-link-params for dynamic :param interpolation
|
|
4065
|
+
const paramsAttr = link.getAttribute('z-link-params');
|
|
4066
|
+
if (paramsAttr) {
|
|
4067
|
+
try {
|
|
4068
|
+
const params = JSON.parse(paramsAttr);
|
|
4069
|
+
href = this._interpolateParams(href, params);
|
|
4070
|
+
} catch { /* ignore malformed JSON */ }
|
|
4071
|
+
}
|
|
4072
|
+
this.navigate(href);
|
|
4073
|
+
// z-to-top modifier: scroll to top after navigation
|
|
4074
|
+
if (link.hasAttribute('z-to-top')) {
|
|
4075
|
+
const scrollBehavior = link.getAttribute('z-to-top') || 'instant';
|
|
4076
|
+
window.scrollTo({ top: 0, behavior: scrollBehavior });
|
|
4077
|
+
}
|
|
3319
4078
|
});
|
|
3320
4079
|
|
|
3321
4080
|
// Initial resolve
|
|
@@ -3359,7 +4118,36 @@ class Router {
|
|
|
3359
4118
|
|
|
3360
4119
|
// --- Navigation ----------------------------------------------------------
|
|
3361
4120
|
|
|
4121
|
+
/**
|
|
4122
|
+
* Interpolate :param placeholders in a path with the given values.
|
|
4123
|
+
* @param {string} path — e.g. '/user/:id/posts/:pid'
|
|
4124
|
+
* @param {Object} params — e.g. { id: 42, pid: 7 }
|
|
4125
|
+
* @returns {string}
|
|
4126
|
+
*/
|
|
4127
|
+
_interpolateParams(path, params) {
|
|
4128
|
+
if (!params || typeof params !== 'object') return path;
|
|
4129
|
+
return path.replace(/:([\w]+)/g, (_, key) => {
|
|
4130
|
+
const val = params[key];
|
|
4131
|
+
return val != null ? encodeURIComponent(String(val)) : ':' + key;
|
|
4132
|
+
});
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
/**
|
|
4136
|
+
* Get the full current URL (path + hash) for same-URL detection.
|
|
4137
|
+
* @returns {string}
|
|
4138
|
+
*/
|
|
4139
|
+
_currentURL() {
|
|
4140
|
+
if (this._mode === 'hash') {
|
|
4141
|
+
return window.location.hash.slice(1) || '/';
|
|
4142
|
+
}
|
|
4143
|
+
const pathname = window.location.pathname || '/';
|
|
4144
|
+
const hash = window.location.hash || '';
|
|
4145
|
+
return pathname + hash;
|
|
4146
|
+
}
|
|
4147
|
+
|
|
3362
4148
|
navigate(path, options = {}) {
|
|
4149
|
+
// Interpolate :param placeholders if options.params is provided
|
|
4150
|
+
if (options.params) path = this._interpolateParams(path, options.params);
|
|
3363
4151
|
// Separate hash fragment (e.g. /docs/getting-started#cli-bundler)
|
|
3364
4152
|
const [cleanPath, fragment] = (path || '').split('#');
|
|
3365
4153
|
let normalized = this._normalizePath(cleanPath);
|
|
@@ -3368,15 +4156,56 @@ class Router {
|
|
|
3368
4156
|
// Hash mode uses the URL hash for routing, so a #fragment can't live
|
|
3369
4157
|
// in the URL. Store it as a scroll target for the destination component.
|
|
3370
4158
|
if (fragment) window.__zqScrollTarget = fragment;
|
|
3371
|
-
|
|
4159
|
+
const targetHash = '#' + normalized;
|
|
4160
|
+
// Skip if already at this exact hash (prevents duplicate entries)
|
|
4161
|
+
if (window.location.hash === targetHash && !options.force) return this;
|
|
4162
|
+
window.location.hash = targetHash;
|
|
3372
4163
|
} else {
|
|
3373
|
-
|
|
4164
|
+
const targetURL = this._base + normalized + hash;
|
|
4165
|
+
const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
|
|
4166
|
+
|
|
4167
|
+
if (targetURL === currentURL && !options.force) {
|
|
4168
|
+
// Same full URL (path + hash) — don't push duplicate entry.
|
|
4169
|
+
// If only the hash changed to a fragment target, scroll to it.
|
|
4170
|
+
if (fragment) {
|
|
4171
|
+
const el = document.getElementById(fragment);
|
|
4172
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4173
|
+
}
|
|
4174
|
+
return this;
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
// Same route path but different hash fragment — use replaceState
|
|
4178
|
+
// so back goes to the previous *route*, not the previous scroll position.
|
|
4179
|
+
const targetPathOnly = this._base + normalized;
|
|
4180
|
+
const currentPathOnly = window.location.pathname || '/';
|
|
4181
|
+
if (targetPathOnly === currentPathOnly && hash && !options.force) {
|
|
4182
|
+
window.history.replaceState(
|
|
4183
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
4184
|
+
'',
|
|
4185
|
+
targetURL
|
|
4186
|
+
);
|
|
4187
|
+
// Scroll to the fragment target
|
|
4188
|
+
if (fragment) {
|
|
4189
|
+
const el = document.getElementById(fragment);
|
|
4190
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4191
|
+
}
|
|
4192
|
+
// Don't re-resolve — same route, just a hash change
|
|
4193
|
+
return this;
|
|
4194
|
+
}
|
|
4195
|
+
|
|
4196
|
+
window.history.pushState(
|
|
4197
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
4198
|
+
'',
|
|
4199
|
+
targetURL
|
|
4200
|
+
);
|
|
3374
4201
|
this._resolve();
|
|
3375
4202
|
}
|
|
3376
4203
|
return this;
|
|
3377
4204
|
}
|
|
3378
4205
|
|
|
3379
4206
|
replace(path, options = {}) {
|
|
4207
|
+
// Interpolate :param placeholders if options.params is provided
|
|
4208
|
+
if (options.params) path = this._interpolateParams(path, options.params);
|
|
3380
4209
|
const [cleanPath, fragment] = (path || '').split('#');
|
|
3381
4210
|
let normalized = this._normalizePath(cleanPath);
|
|
3382
4211
|
const hash = fragment ? '#' + fragment : '';
|
|
@@ -3384,7 +4213,11 @@ class Router {
|
|
|
3384
4213
|
if (fragment) window.__zqScrollTarget = fragment;
|
|
3385
4214
|
window.location.replace('#' + normalized);
|
|
3386
4215
|
} else {
|
|
3387
|
-
window.history.replaceState(
|
|
4216
|
+
window.history.replaceState(
|
|
4217
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
4218
|
+
'',
|
|
4219
|
+
this._base + normalized + hash
|
|
4220
|
+
);
|
|
3388
4221
|
this._resolve();
|
|
3389
4222
|
}
|
|
3390
4223
|
return this;
|
|
@@ -3439,6 +4272,80 @@ class Router {
|
|
|
3439
4272
|
return () => this._listeners.delete(fn);
|
|
3440
4273
|
}
|
|
3441
4274
|
|
|
4275
|
+
// --- Sub-route history substates -----------------------------------------
|
|
4276
|
+
|
|
4277
|
+
/**
|
|
4278
|
+
* Push a lightweight history entry for in-component UI state.
|
|
4279
|
+
* The URL path does NOT change — only a history entry is added so the
|
|
4280
|
+
* back button can undo the UI change (close modal, revert tab, etc.)
|
|
4281
|
+
* before navigating away.
|
|
4282
|
+
*
|
|
4283
|
+
* @param {string} key — identifier (e.g. 'modal', 'tab', 'panel')
|
|
4284
|
+
* @param {*} data — arbitrary state (serializable)
|
|
4285
|
+
* @returns {Router}
|
|
4286
|
+
*
|
|
4287
|
+
* @example
|
|
4288
|
+
* // Open a modal and push a substate
|
|
4289
|
+
* router.pushSubstate('modal', { id: 'confirm-delete' });
|
|
4290
|
+
* // User hits back → onSubstate fires → close the modal
|
|
4291
|
+
*/
|
|
4292
|
+
pushSubstate(key, data) {
|
|
4293
|
+
this._inSubstate = true;
|
|
4294
|
+
if (this._mode === 'hash') {
|
|
4295
|
+
// Hash mode: stash the substate in a global — hashchange will check.
|
|
4296
|
+
// We still push a history entry via a sentinel hash suffix.
|
|
4297
|
+
const current = window.location.hash || '#/';
|
|
4298
|
+
window.history.pushState(
|
|
4299
|
+
{ [_ZQ_STATE_KEY]: 'substate', key, data },
|
|
4300
|
+
'',
|
|
4301
|
+
window.location.href
|
|
4302
|
+
);
|
|
4303
|
+
} else {
|
|
4304
|
+
window.history.pushState(
|
|
4305
|
+
{ [_ZQ_STATE_KEY]: 'substate', key, data },
|
|
4306
|
+
'',
|
|
4307
|
+
window.location.href // keep same URL
|
|
4308
|
+
);
|
|
4309
|
+
}
|
|
4310
|
+
return this;
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4313
|
+
/**
|
|
4314
|
+
* Register a listener for substate pops (back button on a substate entry).
|
|
4315
|
+
* The callback receives `(key, data)` and should return `true` if it
|
|
4316
|
+
* handled the pop (prevents route resolution). If no listener returns
|
|
4317
|
+
* `true`, normal route resolution proceeds.
|
|
4318
|
+
*
|
|
4319
|
+
* @param {(key: string, data: any, action: string) => boolean|void} fn
|
|
4320
|
+
* @returns {() => void} unsubscribe function
|
|
4321
|
+
*
|
|
4322
|
+
* @example
|
|
4323
|
+
* const unsub = router.onSubstate((key, data) => {
|
|
4324
|
+
* if (key === 'modal') { closeModal(); return true; }
|
|
4325
|
+
* });
|
|
4326
|
+
*/
|
|
4327
|
+
onSubstate(fn) {
|
|
4328
|
+
this._substateListeners.push(fn);
|
|
4329
|
+
return () => {
|
|
4330
|
+
this._substateListeners = this._substateListeners.filter(f => f !== fn);
|
|
4331
|
+
};
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
/**
|
|
4335
|
+
* Fire substate listeners. Returns true if any listener handled it.
|
|
4336
|
+
* @private
|
|
4337
|
+
*/
|
|
4338
|
+
_fireSubstate(key, data, action) {
|
|
4339
|
+
for (const fn of this._substateListeners) {
|
|
4340
|
+
try {
|
|
4341
|
+
if (fn(key, data, action) === true) return true;
|
|
4342
|
+
} catch (err) {
|
|
4343
|
+
reportError(ErrorCode.ROUTER_GUARD, 'onSubstate listener threw', { key, data }, err);
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
return false;
|
|
4347
|
+
}
|
|
4348
|
+
|
|
3442
4349
|
// --- Current state -------------------------------------------------------
|
|
3443
4350
|
|
|
3444
4351
|
get current() { return this._current; }
|
|
@@ -3490,6 +4397,7 @@ class Router {
|
|
|
3490
4397
|
// Prevent re-entrant calls (e.g. listener triggering navigation)
|
|
3491
4398
|
if (this._resolving) return;
|
|
3492
4399
|
this._resolving = true;
|
|
4400
|
+
this._redirectCount = 0;
|
|
3493
4401
|
try {
|
|
3494
4402
|
await this.__resolve();
|
|
3495
4403
|
} finally {
|
|
@@ -3498,6 +4406,16 @@ class Router {
|
|
|
3498
4406
|
}
|
|
3499
4407
|
|
|
3500
4408
|
async __resolve() {
|
|
4409
|
+
// Check if we're landing on a substate entry (e.g. page refresh on a
|
|
4410
|
+
// substate bookmark, or hash-mode popstate). Fire listeners and bail
|
|
4411
|
+
// if handled — the URL hasn't changed so there's no route to resolve.
|
|
4412
|
+
const histState = window.history.state;
|
|
4413
|
+
if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
|
|
4414
|
+
const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
|
|
4415
|
+
if (handled) return;
|
|
4416
|
+
// No listener handled it — fall through to normal routing
|
|
4417
|
+
}
|
|
4418
|
+
|
|
3501
4419
|
const fullPath = this.path;
|
|
3502
4420
|
const [pathPart, queryString] = fullPath.split('?');
|
|
3503
4421
|
const path = pathPart || '/';
|
|
@@ -3525,13 +4443,43 @@ class Router {
|
|
|
3525
4443
|
const to = { route: matched, params, query, path };
|
|
3526
4444
|
const from = this._current;
|
|
3527
4445
|
|
|
4446
|
+
// Same-route optimization: if the resolved route is the same component
|
|
4447
|
+
// with the same params, skip the full destroy/mount cycle and just
|
|
4448
|
+
// update props. This prevents flashing and unnecessary DOM churn.
|
|
4449
|
+
if (from && this._instance && matched.component === from.route.component) {
|
|
4450
|
+
const sameParams = JSON.stringify(params) === JSON.stringify(from.params);
|
|
4451
|
+
const sameQuery = JSON.stringify(query) === JSON.stringify(from.query);
|
|
4452
|
+
if (sameParams && sameQuery) {
|
|
4453
|
+
// Identical navigation — nothing to do
|
|
4454
|
+
return;
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
|
|
3528
4458
|
// Run before guards
|
|
3529
4459
|
for (const guard of this._guards.before) {
|
|
3530
4460
|
try {
|
|
3531
4461
|
const result = await guard(to, from);
|
|
3532
4462
|
if (result === false) return; // Cancel
|
|
3533
4463
|
if (typeof result === 'string') { // Redirect
|
|
3534
|
-
|
|
4464
|
+
if (++this._redirectCount > 10) {
|
|
4465
|
+
reportError(ErrorCode.ROUTER_GUARD, 'Too many guard redirects (possible loop)', { to }, null);
|
|
4466
|
+
return;
|
|
4467
|
+
}
|
|
4468
|
+
// Update URL directly and re-resolve (avoids re-entrancy block)
|
|
4469
|
+
const [rPath, rFrag] = result.split('#');
|
|
4470
|
+
const rNorm = this._normalizePath(rPath || '/');
|
|
4471
|
+
const rHash = rFrag ? '#' + rFrag : '';
|
|
4472
|
+
if (this._mode === 'hash') {
|
|
4473
|
+
if (rFrag) window.__zqScrollTarget = rFrag;
|
|
4474
|
+
window.location.replace('#' + rNorm);
|
|
4475
|
+
} else {
|
|
4476
|
+
window.history.replaceState(
|
|
4477
|
+
{ [_ZQ_STATE_KEY]: 'route' },
|
|
4478
|
+
'',
|
|
4479
|
+
this._base + rNorm + rHash
|
|
4480
|
+
);
|
|
4481
|
+
}
|
|
4482
|
+
return this.__resolve();
|
|
3535
4483
|
}
|
|
3536
4484
|
} catch (err) {
|
|
3537
4485
|
reportError(ErrorCode.ROUTER_GUARD, 'Before-guard threw', { to, from }, err);
|
|
@@ -3552,6 +4500,12 @@ class Router {
|
|
|
3552
4500
|
|
|
3553
4501
|
// Mount component into outlet
|
|
3554
4502
|
if (this._el && matched.component) {
|
|
4503
|
+
// Pre-load external templates/styles so the mount renders synchronously
|
|
4504
|
+
// (keeps old content visible during the fetch instead of showing blank)
|
|
4505
|
+
if (typeof matched.component === 'string') {
|
|
4506
|
+
await prefetch(matched.component);
|
|
4507
|
+
}
|
|
4508
|
+
|
|
3555
4509
|
// Destroy previous
|
|
3556
4510
|
if (this._instance) {
|
|
3557
4511
|
this._instance.destroy();
|
|
@@ -3559,6 +4513,7 @@ class Router {
|
|
|
3559
4513
|
}
|
|
3560
4514
|
|
|
3561
4515
|
// Create container
|
|
4516
|
+
const _routeStart = typeof window !== 'undefined' && window.__zqRenderHook ? performance.now() : 0;
|
|
3562
4517
|
this._el.innerHTML = '';
|
|
3563
4518
|
|
|
3564
4519
|
// Pass route params and query as props
|
|
@@ -3569,10 +4524,12 @@ class Router {
|
|
|
3569
4524
|
const container = document.createElement(matched.component);
|
|
3570
4525
|
this._el.appendChild(container);
|
|
3571
4526
|
this._instance = mount(container, matched.component, props);
|
|
4527
|
+
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
|
|
3572
4528
|
}
|
|
3573
4529
|
// If component is a render function
|
|
3574
4530
|
else if (typeof matched.component === 'function') {
|
|
3575
4531
|
this._el.innerHTML = matched.component(to);
|
|
4532
|
+
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', to);
|
|
3576
4533
|
}
|
|
3577
4534
|
}
|
|
3578
4535
|
|
|
@@ -3590,6 +4547,8 @@ class Router {
|
|
|
3590
4547
|
destroy() {
|
|
3591
4548
|
if (this._instance) this._instance.destroy();
|
|
3592
4549
|
this._listeners.clear();
|
|
4550
|
+
this._substateListeners = [];
|
|
4551
|
+
this._inSubstate = false;
|
|
3593
4552
|
this._routes = [];
|
|
3594
4553
|
this._guards = { before: [], after: [] };
|
|
3595
4554
|
}
|
|
@@ -3610,7 +4569,7 @@ function getRouter() {
|
|
|
3610
4569
|
return _activeRouter;
|
|
3611
4570
|
}
|
|
3612
4571
|
|
|
3613
|
-
// --- src/store.js
|
|
4572
|
+
// --- src/store.js ------------------------------------------------
|
|
3614
4573
|
/**
|
|
3615
4574
|
* zQuery Store — Global reactive state management
|
|
3616
4575
|
*
|
|
@@ -3796,7 +4755,7 @@ function getStore(name = 'default') {
|
|
|
3796
4755
|
return _stores.get(name) || null;
|
|
3797
4756
|
}
|
|
3798
4757
|
|
|
3799
|
-
// --- src/http.js
|
|
4758
|
+
// --- src/http.js -------------------------------------------------
|
|
3800
4759
|
/**
|
|
3801
4760
|
* zQuery HTTP — Lightweight fetch wrapper
|
|
3802
4761
|
*
|
|
@@ -3983,7 +4942,7 @@ const http = {
|
|
|
3983
4942
|
raw: (url, opts) => fetch(url, opts),
|
|
3984
4943
|
};
|
|
3985
4944
|
|
|
3986
|
-
// --- src/utils.js
|
|
4945
|
+
// --- src/utils.js ------------------------------------------------
|
|
3987
4946
|
/**
|
|
3988
4947
|
* zQuery Utils — Common utility functions
|
|
3989
4948
|
*
|
|
@@ -4256,7 +5215,7 @@ class EventBus {
|
|
|
4256
5215
|
|
|
4257
5216
|
const bus = new EventBus();
|
|
4258
5217
|
|
|
4259
|
-
// --- index.js (assembly)
|
|
5218
|
+
// --- index.js (assembly) ------------------------------------------
|
|
4260
5219
|
/**
|
|
4261
5220
|
* ┌---------------------------------------------------------┐
|
|
4262
5221
|
* │ zQuery (zeroQuery) — Lightweight Frontend Library │
|
|
@@ -4352,8 +5311,10 @@ $.mountAll = mountAll;
|
|
|
4352
5311
|
$.getInstance = getInstance;
|
|
4353
5312
|
$.destroy = destroy;
|
|
4354
5313
|
$.components = getRegistry;
|
|
5314
|
+
$.prefetch = prefetch;
|
|
4355
5315
|
$.style = style;
|
|
4356
|
-
$.morph
|
|
5316
|
+
$.morph = morph;
|
|
5317
|
+
$.morphElement = morphElement;
|
|
4357
5318
|
$.safeEval = safeEval;
|
|
4358
5319
|
|
|
4359
5320
|
// --- Router ----------------------------------------------------------------
|
|
@@ -4394,12 +5355,15 @@ $.session = session;
|
|
|
4394
5355
|
$.bus = bus;
|
|
4395
5356
|
|
|
4396
5357
|
// --- Error handling --------------------------------------------------------
|
|
4397
|
-
$.onError
|
|
4398
|
-
$.ZQueryError
|
|
4399
|
-
$.ErrorCode
|
|
5358
|
+
$.onError = onError;
|
|
5359
|
+
$.ZQueryError = ZQueryError;
|
|
5360
|
+
$.ErrorCode = ErrorCode;
|
|
5361
|
+
$.guardCallback = guardCallback;
|
|
5362
|
+
$.validate = validate;
|
|
4400
5363
|
|
|
4401
5364
|
// --- Meta ------------------------------------------------------------------
|
|
4402
|
-
$.version = '0.6
|
|
5365
|
+
$.version = '0.8.6';
|
|
5366
|
+
$.libSize = '~91 KB';
|
|
4403
5367
|
$.meta = {}; // populated at build time by CLI bundler
|
|
4404
5368
|
|
|
4405
5369
|
$.noConflict = () => {
|