zero-query 0.7.5 → 0.8.7
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 -30
- package/cli/commands/build.js +110 -1
- package/cli/commands/bundle.js +127 -50
- 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 +28 -3
- package/cli/commands/dev/logger.js +6 -1
- package/cli/commands/dev/overlay.js +377 -0
- package/cli/commands/dev/server.js +8 -0
- package/cli/commands/dev/watcher.js +26 -1
- package/cli/help.js +8 -5
- package/cli/scaffold/{scripts → app}/app.js +1 -1
- package/cli/scaffold/{scripts → app}/components/about.js +4 -4
- package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
- 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} +3 -2
- package/cli/scaffold/index.html +11 -11
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +740 -226
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +11 -11
- package/index.js +15 -10
- package/package.json +3 -2
- package/src/component.js +154 -139
- package/src/core.js +57 -11
- package/src/diff.js +256 -58
- package/src/expression.js +33 -3
- package/src/reactive.js +37 -5
- package/src/router.js +196 -7
- package/src/ssr.js +1 -1
- package/tests/component.test.js +582 -0
- package/tests/core.test.js +251 -0
- package/tests/diff.test.js +333 -2
- package/tests/expression.test.js +148 -0
- package/tests/http.test.js +108 -0
- package/tests/reactive.test.js +148 -0
- package/tests/router.test.js +317 -0
- package/tests/store.test.js +126 -0
- package/tests/utils.test.js +161 -2
- package/types/collection.d.ts +17 -2
- package/types/component.d.ts +10 -34
- package/types/misc.d.ts +13 -0
- package/types/router.d.ts +30 -1
- 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.css +0 -0
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
- /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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zero-query",
|
|
3
|
-
"version": "0.7
|
|
3
|
+
"version": "0.8.7",
|
|
4
4
|
"description": "Lightweight modern frontend library — jQuery-like selectors, reactive components, SPA router, and state management with zero dependencies.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "node cli/index.js build",
|
|
23
23
|
"dev": "node cli/index.js dev zquery-website",
|
|
24
|
+
"dev:bundle": "node cli/index.js dev zquery-website --bundle",
|
|
24
25
|
"dev-lib": "node cli/index.js build --watch",
|
|
25
26
|
"bundle": "node cli/index.js bundle",
|
|
26
|
-
"bundle:app": "node cli/index.js bundle zquery-website
|
|
27
|
+
"bundle:app": "node cli/index.js bundle zquery-website",
|
|
27
28
|
"test": "vitest run",
|
|
28
29
|
"test:watch": "vitest"
|
|
29
30
|
},
|
package/src/component.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - Scoped styles (inline or via styleUrl)
|
|
16
16
|
* - External templates via templateUrl (with {{expression}} interpolation)
|
|
17
17
|
* - External styles via styleUrl (fetched & scoped automatically)
|
|
18
|
-
* - Relative path resolution — templateUrl
|
|
18
|
+
* - Relative path resolution — templateUrl and styleUrl
|
|
19
19
|
* resolve relative to the component file automatically
|
|
20
20
|
*/
|
|
21
21
|
|
|
@@ -86,16 +86,6 @@ function _fetchResource(url) {
|
|
|
86
86
|
return promise;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/**
|
|
90
|
-
* Convert a kebab-case id to Title Case.
|
|
91
|
-
* 'getting-started' → 'Getting Started'
|
|
92
|
-
* @param {string} id
|
|
93
|
-
* @returns {string}
|
|
94
|
-
*/
|
|
95
|
-
function _titleCase(id) {
|
|
96
|
-
return id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
97
|
-
}
|
|
98
|
-
|
|
99
89
|
/**
|
|
100
90
|
* Resolve a relative URL against a base.
|
|
101
91
|
*
|
|
@@ -314,47 +304,10 @@ class Component {
|
|
|
314
304
|
// - string → single stylesheet
|
|
315
305
|
// - string[] → array of URLs → all fetched & concatenated
|
|
316
306
|
//
|
|
317
|
-
// pages config (shorthand for multi-template + route-param page switching):
|
|
318
|
-
// pages: {
|
|
319
|
-
// dir: 'pages', // relative to component file (or base)
|
|
320
|
-
// param: 'section', // route param name → this.activePage
|
|
321
|
-
// default: 'getting-started', // fallback when param is absent
|
|
322
|
-
// ext: '.html', // file extension (default '.html')
|
|
323
|
-
// items: ['page-a', { id: 'page-b', label: 'Page B' }, ...]
|
|
324
|
-
// }
|
|
325
|
-
// Exposes this.pages (array of {id,label}), this.activePage (current id)
|
|
326
|
-
// Pages are lazy-loaded: only the active page is fetched on first render,
|
|
327
|
-
// remaining pages are prefetched in the background for instant navigation.
|
|
328
|
-
//
|
|
329
307
|
async _loadExternals() {
|
|
330
308
|
const def = this._def;
|
|
331
309
|
const base = def._base; // auto-detected or explicit
|
|
332
310
|
|
|
333
|
-
// -- Pages config ---------------------------------------------
|
|
334
|
-
if (def.pages && !def._pagesNormalized) {
|
|
335
|
-
const p = def.pages;
|
|
336
|
-
const ext = p.ext || '.html';
|
|
337
|
-
const dir = _resolveUrl((p.dir || '').replace(/\/+$/, ''), base);
|
|
338
|
-
|
|
339
|
-
// Normalize items → [{id, label}, …]
|
|
340
|
-
def._pages = (p.items || []).map(item => {
|
|
341
|
-
if (typeof item === 'string') return { id: item, label: _titleCase(item) };
|
|
342
|
-
return { id: item.id, label: item.label || _titleCase(item.id) };
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
// Build URL map for lazy per-page loading.
|
|
346
|
-
// Pages are fetched on demand (active page first, rest prefetched in
|
|
347
|
-
// the background) so the component renders as soon as the visible
|
|
348
|
-
// page is ready instead of waiting for every page to download.
|
|
349
|
-
def._pageUrls = {};
|
|
350
|
-
for (const { id } of def._pages) {
|
|
351
|
-
def._pageUrls[id] = `${dir}/${id}${ext}`;
|
|
352
|
-
}
|
|
353
|
-
if (!def._externalTemplates) def._externalTemplates = {};
|
|
354
|
-
|
|
355
|
-
def._pagesNormalized = true;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
311
|
// -- External templates --------------------------------------
|
|
359
312
|
if (def.templateUrl && !def._templateLoaded) {
|
|
360
313
|
const tu = def.templateUrl;
|
|
@@ -367,9 +320,8 @@ class Component {
|
|
|
367
320
|
results.forEach((html, i) => { def._externalTemplates[i] = html; });
|
|
368
321
|
} else if (typeof tu === 'object') {
|
|
369
322
|
const entries = Object.entries(tu);
|
|
370
|
-
// Pages config already resolved; plain objects still need resolving
|
|
371
323
|
const results = await Promise.all(
|
|
372
|
-
entries.map(([, url]) => _fetchResource(
|
|
324
|
+
entries.map(([, url]) => _fetchResource(_resolveUrl(url, base)))
|
|
373
325
|
);
|
|
374
326
|
def._externalTemplates = {};
|
|
375
327
|
entries.forEach(([key], i) => { def._externalTemplates[key] = results[i]; });
|
|
@@ -398,8 +350,7 @@ class Component {
|
|
|
398
350
|
_render() {
|
|
399
351
|
// If externals haven't loaded yet, trigger async load then re-render
|
|
400
352
|
if ((this._def.templateUrl && !this._def._templateLoaded) ||
|
|
401
|
-
(this._def.styleUrl && !this._def._styleLoaded)
|
|
402
|
-
(this._def.pages && !this._def._pagesNormalized)) {
|
|
353
|
+
(this._def.styleUrl && !this._def._styleLoaded)) {
|
|
403
354
|
this._loadExternals().then(() => {
|
|
404
355
|
if (!this._destroyed) this._render();
|
|
405
356
|
});
|
|
@@ -411,43 +362,6 @@ class Component {
|
|
|
411
362
|
this.templates = this._def._externalTemplates;
|
|
412
363
|
}
|
|
413
364
|
|
|
414
|
-
// Expose pages metadata and active page (derived from route param)
|
|
415
|
-
if (this._def._pages) {
|
|
416
|
-
this.pages = this._def._pages;
|
|
417
|
-
const pc = this._def.pages;
|
|
418
|
-
let active = (pc.param && this.props.$params?.[pc.param]) || pc.default || this._def._pages[0]?.id || '';
|
|
419
|
-
|
|
420
|
-
// Fall back to default if the param doesn't match any known page
|
|
421
|
-
if (this._def._pageUrls && !(active in this._def._pageUrls)) {
|
|
422
|
-
active = pc.default || this._def._pages[0]?.id || '';
|
|
423
|
-
}
|
|
424
|
-
this.activePage = active;
|
|
425
|
-
|
|
426
|
-
// Lazy-load: fetch only the active page's template on demand
|
|
427
|
-
if (this._def._pageUrls && !(active in this._def._externalTemplates)) {
|
|
428
|
-
const url = this._def._pageUrls[active];
|
|
429
|
-
if (url) {
|
|
430
|
-
_fetchResource(url).then(html => {
|
|
431
|
-
this._def._externalTemplates[active] = html;
|
|
432
|
-
if (!this._destroyed) this._render();
|
|
433
|
-
});
|
|
434
|
-
return; // Wait for active page before rendering
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Prefetch remaining pages in background (once, after active page is ready)
|
|
439
|
-
if (this._def._pageUrls && !this._def._pagesPrefetched) {
|
|
440
|
-
this._def._pagesPrefetched = true;
|
|
441
|
-
for (const [id, url] of Object.entries(this._def._pageUrls)) {
|
|
442
|
-
if (!(id in this._def._externalTemplates)) {
|
|
443
|
-
_fetchResource(url).then(html => {
|
|
444
|
-
this._def._externalTemplates[id] = html;
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
365
|
// Determine HTML content
|
|
452
366
|
let html;
|
|
453
367
|
if (this._def.render) {
|
|
@@ -472,6 +386,11 @@ class Component {
|
|
|
472
386
|
html = '';
|
|
473
387
|
}
|
|
474
388
|
|
|
389
|
+
// Pre-expand z-html and z-text at string level so the morph engine
|
|
390
|
+
// can diff their content properly (instead of clearing + re-injecting
|
|
391
|
+
// on every re-render). Same pattern as z-for: parse → evaluate → serialize.
|
|
392
|
+
html = this._expandContentDirectives(html);
|
|
393
|
+
|
|
475
394
|
// -- Slot distribution ----------------------------------------
|
|
476
395
|
// Replace <slot> elements with captured slot content from parent.
|
|
477
396
|
// <slot> → default slot content
|
|
@@ -561,8 +480,10 @@ class Component {
|
|
|
561
480
|
|
|
562
481
|
// Update DOM via morphing (diffing) — preserves unchanged nodes
|
|
563
482
|
// First render uses innerHTML for speed; subsequent renders morph.
|
|
483
|
+
const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
|
|
564
484
|
if (!this._mounted) {
|
|
565
485
|
this._el.innerHTML = html;
|
|
486
|
+
if (_renderStart && window.__zqRenderHook) window.__zqRenderHook(this._el, performance.now() - _renderStart, 'mount', this._def._name);
|
|
566
487
|
} else {
|
|
567
488
|
morph(this._el, html);
|
|
568
489
|
}
|
|
@@ -605,31 +526,31 @@ class Component {
|
|
|
605
526
|
}
|
|
606
527
|
}
|
|
607
528
|
|
|
608
|
-
// Bind @event="method" and z-on:event="method" handlers via delegation
|
|
529
|
+
// Bind @event="method" and z-on:event="method" handlers via delegation.
|
|
530
|
+
//
|
|
531
|
+
// Optimization: on the FIRST render, we scan for event attributes, build
|
|
532
|
+
// a delegated handler map, and attach one listener per event type to the
|
|
533
|
+
// component root. On subsequent renders (re-bind), we only rebuild the
|
|
534
|
+
// internal binding map — existing DOM listeners are reused since they
|
|
535
|
+
// delegate to event.target.closest(selector) at fire time.
|
|
609
536
|
_bindEvents() {
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
this._el.removeEventListener(event, handler);
|
|
613
|
-
});
|
|
614
|
-
this._listeners = [];
|
|
537
|
+
// Always rebuild the binding map from current DOM
|
|
538
|
+
const eventMap = new Map(); // event → [{ selector, methodExpr, modifiers, el }]
|
|
615
539
|
|
|
616
|
-
// Find all elements with @event or z-on:event attributes
|
|
617
540
|
const allEls = this._el.querySelectorAll('*');
|
|
618
|
-
const eventMap = new Map(); // event → [{ selector, method, modifiers }]
|
|
619
|
-
|
|
620
541
|
allEls.forEach(child => {
|
|
621
|
-
// Skip elements inside z-pre subtrees
|
|
622
542
|
if (child.closest('[z-pre]')) return;
|
|
623
543
|
|
|
624
|
-
|
|
625
|
-
|
|
544
|
+
const attrs = child.attributes;
|
|
545
|
+
for (let a = 0; a < attrs.length; a++) {
|
|
546
|
+
const attr = attrs[a];
|
|
626
547
|
let raw;
|
|
627
|
-
if (attr.name.
|
|
628
|
-
raw = attr.name.slice(1);
|
|
548
|
+
if (attr.name.charCodeAt(0) === 64) { // '@'
|
|
549
|
+
raw = attr.name.slice(1);
|
|
629
550
|
} else if (attr.name.startsWith('z-on:')) {
|
|
630
|
-
raw = attr.name.slice(5);
|
|
551
|
+
raw = attr.name.slice(5);
|
|
631
552
|
} else {
|
|
632
|
-
|
|
553
|
+
continue;
|
|
633
554
|
}
|
|
634
555
|
|
|
635
556
|
const parts = raw.split('.');
|
|
@@ -645,12 +566,45 @@ class Component {
|
|
|
645
566
|
|
|
646
567
|
if (!eventMap.has(event)) eventMap.set(event, []);
|
|
647
568
|
eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
|
|
648
|
-
}
|
|
569
|
+
}
|
|
649
570
|
});
|
|
650
571
|
|
|
572
|
+
// Store binding map for the delegated handlers to reference
|
|
573
|
+
this._eventBindings = eventMap;
|
|
574
|
+
|
|
575
|
+
// Only attach DOM listeners once — reuse on subsequent renders.
|
|
576
|
+
// The handlers close over `this` and read `this._eventBindings`
|
|
577
|
+
// at fire time, so they always use the latest binding map.
|
|
578
|
+
if (this._delegatedEvents) {
|
|
579
|
+
// Already attached — just make sure new event types are covered
|
|
580
|
+
for (const event of eventMap.keys()) {
|
|
581
|
+
if (!this._delegatedEvents.has(event)) {
|
|
582
|
+
this._attachDelegatedEvent(event, eventMap.get(event));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// Remove listeners for event types no longer in the template
|
|
586
|
+
for (const event of this._delegatedEvents.keys()) {
|
|
587
|
+
if (!eventMap.has(event)) {
|
|
588
|
+
const { handler, opts } = this._delegatedEvents.get(event);
|
|
589
|
+
this._el.removeEventListener(event, handler, opts);
|
|
590
|
+
this._delegatedEvents.delete(event);
|
|
591
|
+
// Also remove from _listeners array
|
|
592
|
+
this._listeners = this._listeners.filter(l => l.event !== event);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
this._delegatedEvents = new Map();
|
|
599
|
+
|
|
651
600
|
// Register delegated listeners on the component root
|
|
652
601
|
for (const [event, bindings] of eventMap) {
|
|
653
|
-
|
|
602
|
+
this._attachDelegatedEvent(event, bindings);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Attach a single delegated listener for an event type
|
|
607
|
+
_attachDelegatedEvent(event, bindings) {
|
|
654
608
|
const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
|
|
655
609
|
const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
|
|
656
610
|
const listenerOpts = (needsCapture || needsPassive)
|
|
@@ -658,7 +612,9 @@ class Component {
|
|
|
658
612
|
: false;
|
|
659
613
|
|
|
660
614
|
const handler = (e) => {
|
|
661
|
-
|
|
615
|
+
// Read bindings from live map — always up to date after re-renders
|
|
616
|
+
const currentBindings = this._eventBindings?.get(event) || [];
|
|
617
|
+
for (const { selector, methodExpr, modifiers, el } of currentBindings) {
|
|
662
618
|
if (!e.target.closest(selector)) continue;
|
|
663
619
|
|
|
664
620
|
// .self — only fire if target is the element itself
|
|
@@ -728,7 +684,7 @@ class Component {
|
|
|
728
684
|
};
|
|
729
685
|
this._el.addEventListener(event, handler, listenerOpts);
|
|
730
686
|
this._listeners.push({ event, handler });
|
|
731
|
-
|
|
687
|
+
this._delegatedEvents.set(event, { handler, opts: listenerOpts });
|
|
732
688
|
}
|
|
733
689
|
|
|
734
690
|
// Bind z-ref="name" → this.refs.name
|
|
@@ -767,7 +723,7 @@ class Component {
|
|
|
767
723
|
// Read current state value (supports dot-path keys)
|
|
768
724
|
const currentVal = _getPath(this.state, key);
|
|
769
725
|
|
|
770
|
-
// -- Set initial DOM value from state
|
|
726
|
+
// -- Set initial DOM value from state (always sync) ----------
|
|
771
727
|
if (tag === 'input' && type === 'checkbox') {
|
|
772
728
|
el.checked = !!currentVal;
|
|
773
729
|
} else if (tag === 'input' && type === 'radio') {
|
|
@@ -789,6 +745,11 @@ class Component {
|
|
|
789
745
|
: isEditable ? 'input' : 'input';
|
|
790
746
|
|
|
791
747
|
// -- Handler: read DOM → write to reactive state -------------
|
|
748
|
+
// Skip if already bound (morph preserves existing elements,
|
|
749
|
+
// so re-binding would stack duplicate listeners)
|
|
750
|
+
if (el._zqModelBound) return;
|
|
751
|
+
el._zqModelBound = true;
|
|
752
|
+
|
|
792
753
|
const handler = () => {
|
|
793
754
|
let val;
|
|
794
755
|
if (type === 'checkbox') val = el.checked;
|
|
@@ -908,6 +869,41 @@ class Component {
|
|
|
908
869
|
return temp.innerHTML;
|
|
909
870
|
}
|
|
910
871
|
|
|
872
|
+
// ---------------------------------------------------------------------------
|
|
873
|
+
// _expandContentDirectives — Pre-morph z-html & z-text expansion
|
|
874
|
+
//
|
|
875
|
+
// Evaluates z-html and z-text directives at the string level so the morph
|
|
876
|
+
// engine receives HTML with the actual content inline. This lets the diff
|
|
877
|
+
// algorithm properly compare old vs new content (text nodes, child elements)
|
|
878
|
+
// instead of clearing + re-injecting on every re-render.
|
|
879
|
+
//
|
|
880
|
+
// Same parse → evaluate → serialize pattern as _expandZFor.
|
|
881
|
+
// ---------------------------------------------------------------------------
|
|
882
|
+
_expandContentDirectives(html) {
|
|
883
|
+
if (!html.includes('z-html') && !html.includes('z-text')) return html;
|
|
884
|
+
|
|
885
|
+
const temp = document.createElement('div');
|
|
886
|
+
temp.innerHTML = html;
|
|
887
|
+
|
|
888
|
+
// z-html: evaluate expression → inject as innerHTML
|
|
889
|
+
temp.querySelectorAll('[z-html]').forEach(el => {
|
|
890
|
+
if (el.closest('[z-pre]')) return;
|
|
891
|
+
const val = this._evalExpr(el.getAttribute('z-html'));
|
|
892
|
+
el.innerHTML = val != null ? String(val) : '';
|
|
893
|
+
el.removeAttribute('z-html');
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// z-text: evaluate expression → inject as textContent (HTML-safe)
|
|
897
|
+
temp.querySelectorAll('[z-text]').forEach(el => {
|
|
898
|
+
if (el.closest('[z-pre]')) return;
|
|
899
|
+
const val = this._evalExpr(el.getAttribute('z-text'));
|
|
900
|
+
el.textContent = val != null ? String(val) : '';
|
|
901
|
+
el.removeAttribute('z-text');
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
return temp.innerHTML;
|
|
905
|
+
}
|
|
906
|
+
|
|
911
907
|
// ---------------------------------------------------------------------------
|
|
912
908
|
// _processDirectives — Post-innerHTML DOM-level directive processing
|
|
913
909
|
// ---------------------------------------------------------------------------
|
|
@@ -960,25 +956,36 @@ class Component {
|
|
|
960
956
|
});
|
|
961
957
|
|
|
962
958
|
// -- z-bind:attr / :attr (dynamic attribute binding) -----------
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
959
|
+
// Use TreeWalker instead of querySelectorAll('*') — avoids
|
|
960
|
+
// creating a flat array of every single descendant element.
|
|
961
|
+
// TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
|
|
962
|
+
// at the walker level (faster than per-node closest('[z-pre]') checks).
|
|
963
|
+
const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
|
|
964
|
+
acceptNode(n) {
|
|
965
|
+
return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
let node;
|
|
969
|
+
while ((node = walker.nextNode())) {
|
|
970
|
+
const attrs = node.attributes;
|
|
971
|
+
for (let i = attrs.length - 1; i >= 0; i--) {
|
|
972
|
+
const attr = attrs[i];
|
|
966
973
|
let attrName;
|
|
967
974
|
if (attr.name.startsWith('z-bind:')) attrName = attr.name.slice(7);
|
|
968
|
-
else if (attr.name.
|
|
969
|
-
else
|
|
975
|
+
else if (attr.name.charCodeAt(0) === 58 && attr.name.charCodeAt(1) !== 58) attrName = attr.name.slice(1); // ':' but not '::'
|
|
976
|
+
else continue;
|
|
970
977
|
|
|
971
978
|
const val = this._evalExpr(attr.value);
|
|
972
|
-
|
|
979
|
+
node.removeAttribute(attr.name);
|
|
973
980
|
if (val === false || val === null || val === undefined) {
|
|
974
|
-
|
|
981
|
+
node.removeAttribute(attrName);
|
|
975
982
|
} else if (val === true) {
|
|
976
|
-
|
|
983
|
+
node.setAttribute(attrName, '');
|
|
977
984
|
} else {
|
|
978
|
-
|
|
985
|
+
node.setAttribute(attrName, String(val));
|
|
979
986
|
}
|
|
980
|
-
}
|
|
981
|
-
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
982
989
|
|
|
983
990
|
// -- z-class (dynamic class binding) ---------------------------
|
|
984
991
|
this._el.querySelectorAll('[z-class]').forEach(el => {
|
|
@@ -1010,21 +1017,9 @@ class Component {
|
|
|
1010
1017
|
el.removeAttribute('z-style');
|
|
1011
1018
|
});
|
|
1012
1019
|
|
|
1013
|
-
//
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
const val = this._evalExpr(el.getAttribute('z-html'));
|
|
1017
|
-
el.innerHTML = val != null ? String(val) : '';
|
|
1018
|
-
el.removeAttribute('z-html');
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
// -- z-text (safe textContent binding) -------------------------
|
|
1022
|
-
this._el.querySelectorAll('[z-text]').forEach(el => {
|
|
1023
|
-
if (el.closest('[z-pre]')) return;
|
|
1024
|
-
const val = this._evalExpr(el.getAttribute('z-text'));
|
|
1025
|
-
el.textContent = val != null ? String(val) : '';
|
|
1026
|
-
el.removeAttribute('z-text');
|
|
1027
|
-
});
|
|
1020
|
+
// z-html and z-text are now pre-expanded at string level (before
|
|
1021
|
+
// morph) via _expandContentDirectives(), so the diff engine can
|
|
1022
|
+
// properly diff their content instead of clearing + re-injecting.
|
|
1028
1023
|
|
|
1029
1024
|
// -- z-cloak (remove after render) -----------------------------
|
|
1030
1025
|
this._el.querySelectorAll('[z-cloak]').forEach(el => {
|
|
@@ -1057,6 +1052,8 @@ class Component {
|
|
|
1057
1052
|
}
|
|
1058
1053
|
this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
|
|
1059
1054
|
this._listeners = [];
|
|
1055
|
+
this._delegatedEvents = null;
|
|
1056
|
+
this._eventBindings = null;
|
|
1060
1057
|
if (this._styleEl) this._styleEl.remove();
|
|
1061
1058
|
_instances.delete(this._el);
|
|
1062
1059
|
this._el.innerHTML = '';
|
|
@@ -1067,7 +1064,7 @@ class Component {
|
|
|
1067
1064
|
// Reserved definition keys (not user methods)
|
|
1068
1065
|
const _reservedKeys = new Set([
|
|
1069
1066
|
'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
|
|
1070
|
-
'templateUrl', 'styleUrl', 'templates', '
|
|
1067
|
+
'templateUrl', 'styleUrl', 'templates', 'base',
|
|
1071
1068
|
'computed', 'watch'
|
|
1072
1069
|
]);
|
|
1073
1070
|
|
|
@@ -1090,8 +1087,8 @@ export function component(name, definition) {
|
|
|
1090
1087
|
}
|
|
1091
1088
|
definition._name = name;
|
|
1092
1089
|
|
|
1093
|
-
// Auto-detect the calling module's URL so that relative templateUrl
|
|
1094
|
-
//
|
|
1090
|
+
// Auto-detect the calling module's URL so that relative templateUrl
|
|
1091
|
+
// and styleUrl paths resolve relative to the component file.
|
|
1095
1092
|
// An explicit `base` string on the definition overrides auto-detection.
|
|
1096
1093
|
if (definition.base !== undefined) {
|
|
1097
1094
|
definition._base = definition.base; // explicit override
|
|
@@ -1207,6 +1204,24 @@ export function getRegistry() {
|
|
|
1207
1204
|
return Object.fromEntries(_registry);
|
|
1208
1205
|
}
|
|
1209
1206
|
|
|
1207
|
+
/**
|
|
1208
|
+
* Pre-load a component's external templates and styles so the next mount
|
|
1209
|
+
* renders synchronously (no blank flash while fetching).
|
|
1210
|
+
* Safe to call multiple times — skips if already loaded.
|
|
1211
|
+
* @param {string} name — registered component name
|
|
1212
|
+
* @returns {Promise<void>}
|
|
1213
|
+
*/
|
|
1214
|
+
export async function prefetch(name) {
|
|
1215
|
+
const def = _registry.get(name);
|
|
1216
|
+
if (!def) return;
|
|
1217
|
+
|
|
1218
|
+
// Load templateUrl and styleUrl if not already loaded.
|
|
1219
|
+
if ((def.templateUrl && !def._templateLoaded) ||
|
|
1220
|
+
(def.styleUrl && !def._styleLoaded)) {
|
|
1221
|
+
await Component.prototype._loadExternals.call({ _def: def });
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1210
1225
|
|
|
1211
1226
|
// ---------------------------------------------------------------------------
|
|
1212
1227
|
// Global stylesheet loader
|
package/src/core.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* into a full jQuery-like chainable wrapper with modern APIs.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { morph as _morph, morphElement as _morphElement } from './diff.js';
|
|
9
|
+
|
|
8
10
|
// ---------------------------------------------------------------------------
|
|
9
11
|
// ZQueryCollection — wraps an array of elements with chainable methods
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
@@ -223,21 +225,46 @@ export class ZQueryCollection {
|
|
|
223
225
|
// --- Classes -------------------------------------------------------------
|
|
224
226
|
|
|
225
227
|
addClass(...names) {
|
|
228
|
+
// Fast path: single class, no spaces — avoids flatMap + regex split allocation
|
|
229
|
+
if (names.length === 1 && names[0].indexOf(' ') === -1) {
|
|
230
|
+
const c = names[0];
|
|
231
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(c);
|
|
232
|
+
return this;
|
|
233
|
+
}
|
|
226
234
|
const classes = names.flatMap(n => n.split(/\s+/));
|
|
227
|
-
|
|
235
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(...classes);
|
|
236
|
+
return this;
|
|
228
237
|
}
|
|
229
238
|
|
|
230
239
|
removeClass(...names) {
|
|
240
|
+
if (names.length === 1 && names[0].indexOf(' ') === -1) {
|
|
241
|
+
const c = names[0];
|
|
242
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(c);
|
|
243
|
+
return this;
|
|
244
|
+
}
|
|
231
245
|
const classes = names.flatMap(n => n.split(/\s+/));
|
|
232
|
-
|
|
246
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(...classes);
|
|
247
|
+
return this;
|
|
233
248
|
}
|
|
234
249
|
|
|
235
250
|
toggleClass(...args) {
|
|
236
251
|
const force = typeof args[args.length - 1] === 'boolean' ? args.pop() : undefined;
|
|
252
|
+
// Fast path: single class, no spaces
|
|
253
|
+
if (args.length === 1 && args[0].indexOf(' ') === -1) {
|
|
254
|
+
const c = args[0];
|
|
255
|
+
for (let i = 0; i < this.elements.length; i++) {
|
|
256
|
+
force !== undefined ? this.elements[i].classList.toggle(c, force) : this.elements[i].classList.toggle(c);
|
|
257
|
+
}
|
|
258
|
+
return this;
|
|
259
|
+
}
|
|
237
260
|
const classes = args.flatMap(n => n.split(/\s+/));
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
261
|
+
for (let i = 0; i < this.elements.length; i++) {
|
|
262
|
+
const el = this.elements[i];
|
|
263
|
+
for (let j = 0; j < classes.length; j++) {
|
|
264
|
+
force !== undefined ? el.classList.toggle(classes[j], force) : el.classList.toggle(classes[j]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return this;
|
|
241
268
|
}
|
|
242
269
|
|
|
243
270
|
hasClass(name) {
|
|
@@ -273,7 +300,8 @@ export class ZQueryCollection {
|
|
|
273
300
|
|
|
274
301
|
css(props) {
|
|
275
302
|
if (typeof props === 'string') {
|
|
276
|
-
|
|
303
|
+
const el = this.first();
|
|
304
|
+
return el ? getComputedStyle(el)[props] : undefined;
|
|
277
305
|
}
|
|
278
306
|
return this.each((_, el) => Object.assign(el.style, props));
|
|
279
307
|
}
|
|
@@ -349,7 +377,21 @@ export class ZQueryCollection {
|
|
|
349
377
|
|
|
350
378
|
html(content) {
|
|
351
379
|
if (content === undefined) return this.first()?.innerHTML;
|
|
352
|
-
|
|
380
|
+
// Auto-morph: if the element already has children, use the diff engine
|
|
381
|
+
// to patch the DOM (preserves focus, scroll, state, keyed reorder via LIS).
|
|
382
|
+
// Empty elements get raw innerHTML for fast first-paint — same strategy
|
|
383
|
+
// the component system uses (first render = innerHTML, updates = morph).
|
|
384
|
+
return this.each((_, el) => {
|
|
385
|
+
if (el.childNodes.length > 0) {
|
|
386
|
+
_morph(el, content);
|
|
387
|
+
} else {
|
|
388
|
+
el.innerHTML = content;
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
morph(content) {
|
|
394
|
+
return this.each((_, el) => { _morph(el, content); });
|
|
353
395
|
}
|
|
354
396
|
|
|
355
397
|
text(content) {
|
|
@@ -406,7 +448,8 @@ export class ZQueryCollection {
|
|
|
406
448
|
}
|
|
407
449
|
|
|
408
450
|
empty() {
|
|
409
|
-
|
|
451
|
+
// textContent = '' clears all children without invoking the HTML parser
|
|
452
|
+
return this.each((_, el) => { el.textContent = ''; });
|
|
410
453
|
}
|
|
411
454
|
|
|
412
455
|
clone(deep = true) {
|
|
@@ -416,8 +459,9 @@ export class ZQueryCollection {
|
|
|
416
459
|
replaceWith(content) {
|
|
417
460
|
return this.each((_, el) => {
|
|
418
461
|
if (typeof content === 'string') {
|
|
419
|
-
|
|
420
|
-
|
|
462
|
+
// Auto-morph: diff attributes + children when the tag name matches
|
|
463
|
+
// instead of destroying and re-creating the element.
|
|
464
|
+
_morphElement(el, content);
|
|
421
465
|
} else if (content instanceof Node) {
|
|
422
466
|
el.parentNode.replaceChild(content, el);
|
|
423
467
|
}
|
|
@@ -503,7 +547,9 @@ export class ZQueryCollection {
|
|
|
503
547
|
|
|
504
548
|
toggle(display = '') {
|
|
505
549
|
return this.each((_, el) => {
|
|
506
|
-
|
|
550
|
+
// Check inline style first (cheap) before forcing layout via getComputedStyle
|
|
551
|
+
const hidden = el.style.display === 'none' || (el.style.display !== '' ? false : getComputedStyle(el).display === 'none');
|
|
552
|
+
el.style.display = hidden ? display : 'none';
|
|
507
553
|
});
|
|
508
554
|
}
|
|
509
555
|
|