zero-query 0.3.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,27 +1,27 @@
1
1
  {
2
2
  "name": "zero-query",
3
- "version": "0.3.1",
3
+ "version": "0.5.2",
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",
7
7
  "bin": {
8
- "zquery": "./cli.js"
8
+ "zquery": "cli/index.js"
9
9
  },
10
10
  "files": [
11
11
  "src",
12
12
  "dist",
13
+ "cli",
13
14
  "index.js",
14
15
  "index.d.ts",
15
- "cli.js",
16
16
  "LICENSE",
17
17
  "README.md"
18
18
  ],
19
19
  "scripts": {
20
- "build": "node build.js",
21
- "dev": "node cli.js dev examples/starter-app",
22
- "dev-lib": "node build.js --watch",
23
- "bundle": "node cli.js bundle",
24
- "bundle:app": "node cli.js bundle examples/starter-app/scripts/app.js"
20
+ "build": "node cli/index.js build",
21
+ "dev": "node cli/index.js dev zquery-website",
22
+ "dev-lib": "node cli/index.js build --watch",
23
+ "bundle": "node cli/index.js bundle",
24
+ "bundle:app": "node cli/index.js bundle zquery-website --minimal"
25
25
  },
26
26
  "keywords": [
27
27
  "dom",
package/src/component.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * zQuery Component — Lightweight reactive component system
3
3
  *
4
- * Declarative components using template literals (no JSX, no build step).
4
+ * Declarative components using template literals with directive support.
5
5
  * Proxy-based state triggers targeted re-renders via event delegation.
6
6
  *
7
7
  * Features:
@@ -31,6 +31,18 @@ const _resourceCache = new Map(); // url → Promise<string>
31
31
  // Unique ID counter
32
32
  let _uid = 0;
33
33
 
34
+ // Inject z-cloak base style and mobile tap-highlight reset (once, globally)
35
+ if (typeof document !== 'undefined' && !document.querySelector('[data-zq-cloak]')) {
36
+ const _s = document.createElement('style');
37
+ _s.textContent = '[z-cloak]{display:none!important}*,*::before,*::after{-webkit-tap-highlight-color:transparent}';
38
+ _s.setAttribute('data-zq-cloak', '');
39
+ document.head.appendChild(_s);
40
+ }
41
+
42
+ // Debounce / throttle helpers for event modifiers
43
+ const _debounceTimers = new WeakMap();
44
+ const _throttleTimers = new WeakMap();
45
+
34
46
  /**
35
47
  * Fetch and cache a text resource (HTML template or CSS file).
36
48
  * @param {string} url — URL to fetch
@@ -215,8 +227,11 @@ class Component {
215
227
  if (this._updateQueued) return;
216
228
  this._updateQueued = true;
217
229
  queueMicrotask(() => {
218
- this._updateQueued = false;
219
- if (!this._destroyed) this._render();
230
+ try {
231
+ if (!this._destroyed) this._render();
232
+ } finally {
233
+ this._updateQueued = false;
234
+ }
220
235
  });
221
236
  }
222
237
 
@@ -244,12 +259,14 @@ class Component {
244
259
  // items: ['page-a', { id: 'page-b', label: 'Page B' }, ...]
245
260
  // }
246
261
  // Exposes this.pages (array of {id,label}), this.activePage (current id)
262
+ // Pages are lazy-loaded: only the active page is fetched on first render,
263
+ // remaining pages are prefetched in the background for instant navigation.
247
264
  //
248
265
  async _loadExternals() {
249
266
  const def = this._def;
250
267
  const base = def._base; // auto-detected or explicit
251
268
 
252
- // ── Pages config ─────────────────────────────────────────────
269
+ // -- Pages config ---------------------------------------------
253
270
  if (def.pages && !def._pagesNormalized) {
254
271
  const p = def.pages;
255
272
  const ext = p.ext || '.html';
@@ -261,18 +278,20 @@ class Component {
261
278
  return { id: item.id, label: item.label || _titleCase(item.id) };
262
279
  });
263
280
 
264
- // Auto-generate templateUrl object map
265
- if (!def.templateUrl) {
266
- def.templateUrl = {};
267
- for (const { id } of def._pages) {
268
- def.templateUrl[id] = `${dir}/${id}${ext}`;
269
- }
281
+ // Build URL map for lazy per-page loading.
282
+ // Pages are fetched on demand (active page first, rest prefetched in
283
+ // the background) so the component renders as soon as the visible
284
+ // page is ready instead of waiting for every page to download.
285
+ def._pageUrls = {};
286
+ for (const { id } of def._pages) {
287
+ def._pageUrls[id] = `${dir}/${id}${ext}`;
270
288
  }
289
+ if (!def._externalTemplates) def._externalTemplates = {};
271
290
 
272
291
  def._pagesNormalized = true;
273
292
  }
274
293
 
275
- // ── External templates ──────────────────────────────────────
294
+ // -- External templates --------------------------------------
276
295
  if (def.templateUrl && !def._templateLoaded) {
277
296
  const tu = def.templateUrl;
278
297
  if (typeof tu === 'string') {
@@ -294,7 +313,7 @@ class Component {
294
313
  def._templateLoaded = true;
295
314
  }
296
315
 
297
- // ── External styles ─────────────────────────────────────────
316
+ // -- External styles -----------------------------------------
298
317
  if (def.styleUrl && !def._styleLoaded) {
299
318
  const su = def.styleUrl;
300
319
  if (typeof su === 'string') {
@@ -329,7 +348,37 @@ class Component {
329
348
  if (this._def._pages) {
330
349
  this.pages = this._def._pages;
331
350
  const pc = this._def.pages;
332
- this.activePage = (pc.param && this.props.$params?.[pc.param]) || pc.default || this._def._pages[0]?.id || '';
351
+ let active = (pc.param && this.props.$params?.[pc.param]) || pc.default || this._def._pages[0]?.id || '';
352
+
353
+ // Fall back to default if the param doesn't match any known page
354
+ if (this._def._pageUrls && !(active in this._def._pageUrls)) {
355
+ active = pc.default || this._def._pages[0]?.id || '';
356
+ }
357
+ this.activePage = active;
358
+
359
+ // Lazy-load: fetch only the active page's template on demand
360
+ if (this._def._pageUrls && !(active in this._def._externalTemplates)) {
361
+ const url = this._def._pageUrls[active];
362
+ if (url) {
363
+ _fetchResource(url).then(html => {
364
+ this._def._externalTemplates[active] = html;
365
+ if (!this._destroyed) this._render();
366
+ });
367
+ return; // Wait for active page before rendering
368
+ }
369
+ }
370
+
371
+ // Prefetch remaining pages in background (once, after active page is ready)
372
+ if (this._def._pageUrls && !this._def._pagesPrefetched) {
373
+ this._def._pagesPrefetched = true;
374
+ for (const [id, url] of Object.entries(this._def._pageUrls)) {
375
+ if (!(id in this._def._externalTemplates)) {
376
+ _fetchResource(url).then(html => {
377
+ this._def._externalTemplates[id] = html;
378
+ });
379
+ }
380
+ }
381
+ }
333
382
  }
334
383
 
335
384
  // Determine HTML content
@@ -337,9 +386,13 @@ class Component {
337
386
  if (this._def.render) {
338
387
  // Inline render function takes priority
339
388
  html = this._def.render.call(this);
389
+ // Expand z-for in render templates ({{}} expressions for iteration items)
390
+ html = this._expandZFor(html);
340
391
  } else if (this._def._externalTemplate) {
341
- // External template with {{expression}} interpolation
342
- html = this._def._externalTemplate.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
392
+ // Expand z-for FIRST (before global {{}} interpolation)
393
+ html = this._expandZFor(this._def._externalTemplate);
394
+ // Then do global {{expression}} interpolation on the remaining content
395
+ html = html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
343
396
  try {
344
397
  return new Function('state', 'props', '$', `with(state){return ${expr.trim()}}`)(
345
398
  this.state.__raw || this.state,
@@ -372,16 +425,35 @@ class Component {
372
425
  this._styleEl = styleEl;
373
426
  }
374
427
 
375
- // ── Focus preservation for z-model ────────────────────────────
428
+ // -- Focus preservation ----------------------------------------
376
429
  // Before replacing innerHTML, save focus state so we can restore
377
- // cursor position after the DOM is rebuilt.
430
+ // cursor position after the DOM is rebuilt. Works for any focused
431
+ // input/textarea/select inside the component, not only z-model.
378
432
  let _focusInfo = null;
379
433
  const _active = document.activeElement;
380
434
  if (_active && this._el.contains(_active)) {
381
435
  const modelKey = _active.getAttribute?.('z-model');
436
+ const refKey = _active.getAttribute?.('z-ref');
437
+ // Build a selector that can locate the same element after re-render
438
+ let selector = null;
382
439
  if (modelKey) {
440
+ selector = `[z-model="${modelKey}"]`;
441
+ } else if (refKey) {
442
+ selector = `[z-ref="${refKey}"]`;
443
+ } else {
444
+ // Fallback: match by tag + type + name + placeholder combination
445
+ const tag = _active.tagName.toLowerCase();
446
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
447
+ let s = tag;
448
+ if (_active.type) s += `[type="${_active.type}"]`;
449
+ if (_active.name) s += `[name="${_active.name}"]`;
450
+ if (_active.placeholder) s += `[placeholder="${CSS.escape(_active.placeholder)}"]`;
451
+ selector = s;
452
+ }
453
+ }
454
+ if (selector) {
383
455
  _focusInfo = {
384
- key: modelKey,
456
+ selector,
385
457
  start: _active.selectionStart,
386
458
  end: _active.selectionEnd,
387
459
  dir: _active.selectionDirection,
@@ -392,14 +464,17 @@ class Component {
392
464
  // Update DOM
393
465
  this._el.innerHTML = html;
394
466
 
395
- // Process directives
467
+ // Process structural & attribute directives
468
+ this._processDirectives();
469
+
470
+ // Process event, ref, and model bindings
396
471
  this._bindEvents();
397
472
  this._bindRefs();
398
473
  this._bindModels();
399
474
 
400
- // Restore focus to z-model element after re-render
475
+ // Restore focus after re-render
401
476
  if (_focusInfo) {
402
- const el = this._el.querySelector(`[z-model="${_focusInfo.key}"]`);
477
+ const el = this._el.querySelector(_focusInfo.selector);
403
478
  if (el) {
404
479
  el.focus();
405
480
  try {
@@ -421,7 +496,7 @@ class Component {
421
496
  }
422
497
  }
423
498
 
424
- // Bind @event="method" handlers via delegation
499
+ // Bind @event="method" and z-on:event="method" handlers via delegation
425
500
  _bindEvents() {
426
501
  // Clean up old delegated listeners
427
502
  this._listeners.forEach(({ event, handler }) => {
@@ -429,15 +504,25 @@ class Component {
429
504
  });
430
505
  this._listeners = [];
431
506
 
432
- // Find all elements with @event attributes
507
+ // Find all elements with @event or z-on:event attributes
433
508
  const allEls = this._el.querySelectorAll('*');
434
509
  const eventMap = new Map(); // event → [{ selector, method, modifiers }]
435
510
 
436
511
  allEls.forEach(child => {
512
+ // Skip elements inside z-pre subtrees
513
+ if (child.closest('[z-pre]')) return;
514
+
437
515
  [...child.attributes].forEach(attr => {
438
- if (!attr.name.startsWith('@')) return;
516
+ // Support both @event and z-on:event syntax
517
+ let raw;
518
+ if (attr.name.startsWith('@')) {
519
+ raw = attr.name.slice(1); // @click.prevent → click.prevent
520
+ } else if (attr.name.startsWith('z-on:')) {
521
+ raw = attr.name.slice(5); // z-on:click.prevent → click.prevent
522
+ } else {
523
+ return;
524
+ }
439
525
 
440
- const raw = attr.name.slice(1); // e.g. "click.prevent"
441
526
  const parts = raw.split('.');
442
527
  const event = parts[0];
443
528
  const modifiers = parts.slice(1);
@@ -456,43 +541,83 @@ class Component {
456
541
 
457
542
  // Register delegated listeners on the component root
458
543
  for (const [event, bindings] of eventMap) {
544
+ // Determine listener options from modifiers
545
+ const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
546
+ const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
547
+ const listenerOpts = (needsCapture || needsPassive)
548
+ ? { capture: needsCapture, passive: needsPassive }
549
+ : false;
550
+
459
551
  const handler = (e) => {
460
552
  for (const { selector, methodExpr, modifiers, el } of bindings) {
461
553
  if (!e.target.closest(selector)) continue;
462
554
 
555
+ // .self — only fire if target is the element itself
556
+ if (modifiers.includes('self') && e.target !== el) continue;
557
+
463
558
  // Handle modifiers
464
559
  if (modifiers.includes('prevent')) e.preventDefault();
465
560
  if (modifiers.includes('stop')) e.stopPropagation();
466
561
 
467
- // Parse method expression: "method" or "method(arg1, arg2)"
468
- const match = methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
469
- if (match) {
562
+ // Build the invocation function
563
+ const invoke = (evt) => {
564
+ const match = methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
565
+ if (!match) return;
470
566
  const methodName = match[1];
471
567
  const fn = this[methodName];
472
- if (typeof fn === 'function') {
473
- if (match[2] !== undefined) {
474
- // Parse arguments (supports strings, numbers, state refs)
475
- const args = match[2].split(',').map(a => {
476
- a = a.trim();
477
- if (a === '') return undefined;
478
- if (a === 'true') return true;
479
- if (a === 'false') return false;
480
- if (a === 'null') return null;
481
- if (/^-?\d+(\.\d+)?$/.test(a)) return Number(a);
482
- if ((a.startsWith("'") && a.endsWith("'")) || (a.startsWith('"') && a.endsWith('"'))) return a.slice(1, -1);
483
- // State reference
484
- if (a.startsWith('state.')) return this.state[a.slice(6)];
485
- return a;
486
- }).filter(a => a !== undefined);
487
- fn(e, ...args);
488
- } else {
489
- fn(e);
490
- }
568
+ if (typeof fn !== 'function') return;
569
+ if (match[2] !== undefined) {
570
+ const args = match[2].split(',').map(a => {
571
+ a = a.trim();
572
+ if (a === '') return undefined;
573
+ if (a === '$event') return evt;
574
+ if (a === 'true') return true;
575
+ if (a === 'false') return false;
576
+ if (a === 'null') return null;
577
+ if (/^-?\d+(\.\d+)?$/.test(a)) return Number(a);
578
+ if ((a.startsWith("'") && a.endsWith("'")) || (a.startsWith('"') && a.endsWith('"'))) return a.slice(1, -1);
579
+ if (a.startsWith('state.')) return _getPath(this.state, a.slice(6));
580
+ return a;
581
+ }).filter(a => a !== undefined);
582
+ fn(...args);
583
+ } else {
584
+ fn(evt);
491
585
  }
586
+ };
587
+
588
+ // .debounce.{ms} — delay invocation until idle
589
+ const debounceIdx = modifiers.indexOf('debounce');
590
+ if (debounceIdx !== -1) {
591
+ const ms = parseInt(modifiers[debounceIdx + 1], 10) || 250;
592
+ const timers = _debounceTimers.get(el) || {};
593
+ clearTimeout(timers[event]);
594
+ timers[event] = setTimeout(() => invoke(e), ms);
595
+ _debounceTimers.set(el, timers);
596
+ continue;
597
+ }
598
+
599
+ // .throttle.{ms} — fire at most once per interval
600
+ const throttleIdx = modifiers.indexOf('throttle');
601
+ if (throttleIdx !== -1) {
602
+ const ms = parseInt(modifiers[throttleIdx + 1], 10) || 250;
603
+ const timers = _throttleTimers.get(el) || {};
604
+ if (timers[event]) continue;
605
+ invoke(e);
606
+ timers[event] = setTimeout(() => { timers[event] = null; }, ms);
607
+ _throttleTimers.set(el, timers);
608
+ continue;
492
609
  }
610
+
611
+ // .once — fire once then ignore
612
+ if (modifiers.includes('once')) {
613
+ if (el.dataset.zqOnce === event) continue;
614
+ el.dataset.zqOnce = event;
615
+ }
616
+
617
+ invoke(e);
493
618
  }
494
619
  };
495
- this._el.addEventListener(event, handler);
620
+ this._el.addEventListener(event, handler, listenerOpts);
496
621
  this._listeners.push({ event, handler });
497
622
  }
498
623
  }
@@ -533,7 +658,7 @@ class Component {
533
658
  // Read current state value (supports dot-path keys)
534
659
  const currentVal = _getPath(this.state, key);
535
660
 
536
- // ── Set initial DOM value from state ────────────────────────
661
+ // -- Set initial DOM value from state ------------------------
537
662
  if (tag === 'input' && type === 'checkbox') {
538
663
  el.checked = !!currentVal;
539
664
  } else if (tag === 'input' && type === 'radio') {
@@ -549,12 +674,12 @@ class Component {
549
674
  el.value = currentVal ?? '';
550
675
  }
551
676
 
552
- // ── Determine event type ────────────────────────────────────
677
+ // -- Determine event type ------------------------------------
553
678
  const event = isLazy || tag === 'select' || type === 'checkbox' || type === 'radio'
554
679
  ? 'change'
555
680
  : isEditable ? 'input' : 'input';
556
681
 
557
- // ── Handler: read DOM → write to reactive state ─────────────
682
+ // -- Handler: read DOM → write to reactive state -------------
558
683
  const handler = () => {
559
684
  let val;
560
685
  if (type === 'checkbox') val = el.checked;
@@ -575,9 +700,240 @@ class Component {
575
700
  });
576
701
  }
577
702
 
703
+ // ---------------------------------------------------------------------------
704
+ // Expression evaluator — runs expr in component context (state, props, refs)
705
+ // ---------------------------------------------------------------------------
706
+ _evalExpr(expr) {
707
+ try {
708
+ return new Function('state', 'props', 'refs', '$',
709
+ `with(state){return (${expr})}`)(
710
+ this.state.__raw || this.state,
711
+ this.props,
712
+ this.refs,
713
+ typeof window !== 'undefined' ? window.$ : undefined
714
+ );
715
+ } catch { return undefined; }
716
+ }
717
+
718
+ // ---------------------------------------------------------------------------
719
+ // z-for — Expand list-rendering directives (pre-innerHTML, string level)
720
+ //
721
+ // <li z-for="item in items">{{item.name}}</li>
722
+ // <li z-for="(item, i) in items">{{i}}: {{item.name}}</li>
723
+ // <div z-for="n in 5">{{n}}</div> (range)
724
+ // <div z-for="(val, key) in obj">{{key}}: {{val}}</div> (object)
725
+ //
726
+ // Uses a temporary DOM to parse, clone elements per item, and evaluate
727
+ // {{}} expressions with the iteration variable in scope.
728
+ // ---------------------------------------------------------------------------
729
+ _expandZFor(html) {
730
+ if (!html.includes('z-for')) return html;
731
+
732
+ const temp = document.createElement('div');
733
+ temp.innerHTML = html;
734
+
735
+ const _recurse = (root) => {
736
+ // Process innermost z-for elements first (no nested z-for inside)
737
+ let forEls = [...root.querySelectorAll('[z-for]')]
738
+ .filter(el => !el.querySelector('[z-for]'));
739
+ if (!forEls.length) return;
740
+
741
+ for (const el of forEls) {
742
+ if (!el.parentNode) continue; // already removed
743
+ const expr = el.getAttribute('z-for');
744
+ const m = expr.match(
745
+ /^\s*(?:\(\s*(\w+)(?:\s*,\s*(\w+))?\s*\)|(\w+))\s+in\s+(.+)\s*$/
746
+ );
747
+ if (!m) { el.removeAttribute('z-for'); continue; }
748
+
749
+ const itemVar = m[1] || m[3];
750
+ const indexVar = m[2] || '$index';
751
+ const listExpr = m[4].trim();
752
+
753
+ let list = this._evalExpr(listExpr);
754
+ if (list == null) { el.remove(); continue; }
755
+ // Number range: z-for="n in 5" → [1, 2, 3, 4, 5]
756
+ if (typeof list === 'number') {
757
+ list = Array.from({ length: list }, (_, i) => i + 1);
758
+ }
759
+ // Object iteration: z-for="(val, key) in obj" → entries
760
+ if (!Array.isArray(list) && typeof list === 'object' && typeof list[Symbol.iterator] !== 'function') {
761
+ list = Object.entries(list).map(([k, v]) => ({ key: k, value: v }));
762
+ }
763
+ if (!Array.isArray(list) && typeof list[Symbol.iterator] === 'function') {
764
+ list = [...list];
765
+ }
766
+ if (!Array.isArray(list)) { el.remove(); continue; }
767
+
768
+ const parent = el.parentNode;
769
+ const tplEl = el.cloneNode(true);
770
+ tplEl.removeAttribute('z-for');
771
+ const tplOuter = tplEl.outerHTML;
772
+
773
+ const fragment = document.createDocumentFragment();
774
+ const evalReplace = (str, item, index) =>
775
+ str.replace(/\{\{(.+?)\}\}/g, (_, inner) => {
776
+ try {
777
+ return new Function(itemVar, indexVar, 'state', 'props', '$',
778
+ `with(state){return (${inner.trim()})}`)(
779
+ item, index,
780
+ this.state.__raw || this.state,
781
+ this.props,
782
+ typeof window !== 'undefined' ? window.$ : undefined
783
+ );
784
+ } catch { return ''; }
785
+ });
786
+
787
+ for (let i = 0; i < list.length; i++) {
788
+ const processed = evalReplace(tplOuter, list[i], i);
789
+ const wrapper = document.createElement('div');
790
+ wrapper.innerHTML = processed;
791
+ while (wrapper.firstChild) fragment.appendChild(wrapper.firstChild);
792
+ }
793
+
794
+ parent.replaceChild(fragment, el);
795
+ }
796
+
797
+ // Handle remaining nested z-for (now exposed)
798
+ if (root.querySelector('[z-for]')) _recurse(root);
799
+ };
800
+
801
+ _recurse(temp);
802
+ return temp.innerHTML;
803
+ }
804
+
805
+ // ---------------------------------------------------------------------------
806
+ // _processDirectives — Post-innerHTML DOM-level directive processing
807
+ // ---------------------------------------------------------------------------
808
+ _processDirectives() {
809
+ // z-pre: skip all directive processing on subtrees
810
+ // (we leave z-pre elements in the DOM, but skip their descendants)
811
+
812
+ // -- z-if / z-else-if / z-else (conditional rendering) --------
813
+ const ifEls = [...this._el.querySelectorAll('[z-if]')];
814
+ for (const el of ifEls) {
815
+ if (!el.parentNode || el.closest('[z-pre]')) continue;
816
+
817
+ const show = !!this._evalExpr(el.getAttribute('z-if'));
818
+
819
+ // Collect chain: adjacent z-else-if / z-else siblings
820
+ const chain = [{ el, show }];
821
+ let sib = el.nextElementSibling;
822
+ while (sib) {
823
+ if (sib.hasAttribute('z-else-if')) {
824
+ chain.push({ el: sib, show: !!this._evalExpr(sib.getAttribute('z-else-if')) });
825
+ sib = sib.nextElementSibling;
826
+ } else if (sib.hasAttribute('z-else')) {
827
+ chain.push({ el: sib, show: true });
828
+ break;
829
+ } else {
830
+ break;
831
+ }
832
+ }
833
+
834
+ // Keep the first truthy branch, remove the rest
835
+ let found = false;
836
+ for (const item of chain) {
837
+ if (!found && item.show) {
838
+ found = true;
839
+ item.el.removeAttribute('z-if');
840
+ item.el.removeAttribute('z-else-if');
841
+ item.el.removeAttribute('z-else');
842
+ } else {
843
+ item.el.remove();
844
+ }
845
+ }
846
+ }
847
+
848
+ // -- z-show (toggle display) -----------------------------------
849
+ this._el.querySelectorAll('[z-show]').forEach(el => {
850
+ if (el.closest('[z-pre]')) return;
851
+ const show = !!this._evalExpr(el.getAttribute('z-show'));
852
+ el.style.display = show ? '' : 'none';
853
+ el.removeAttribute('z-show');
854
+ });
855
+
856
+ // -- z-bind:attr / :attr (dynamic attribute binding) -----------
857
+ this._el.querySelectorAll('*').forEach(el => {
858
+ if (el.closest('[z-pre]')) return;
859
+ [...el.attributes].forEach(attr => {
860
+ let attrName;
861
+ if (attr.name.startsWith('z-bind:')) attrName = attr.name.slice(7);
862
+ else if (attr.name.startsWith(':') && !attr.name.startsWith('::')) attrName = attr.name.slice(1);
863
+ else return;
864
+
865
+ const val = this._evalExpr(attr.value);
866
+ el.removeAttribute(attr.name);
867
+ if (val === false || val === null || val === undefined) {
868
+ el.removeAttribute(attrName);
869
+ } else if (val === true) {
870
+ el.setAttribute(attrName, '');
871
+ } else {
872
+ el.setAttribute(attrName, String(val));
873
+ }
874
+ });
875
+ });
876
+
877
+ // -- z-class (dynamic class binding) ---------------------------
878
+ this._el.querySelectorAll('[z-class]').forEach(el => {
879
+ if (el.closest('[z-pre]')) return;
880
+ const val = this._evalExpr(el.getAttribute('z-class'));
881
+ if (typeof val === 'string') {
882
+ val.split(/\s+/).filter(Boolean).forEach(c => el.classList.add(c));
883
+ } else if (Array.isArray(val)) {
884
+ val.filter(Boolean).forEach(c => el.classList.add(String(c)));
885
+ } else if (val && typeof val === 'object') {
886
+ for (const [cls, active] of Object.entries(val)) {
887
+ el.classList.toggle(cls, !!active);
888
+ }
889
+ }
890
+ el.removeAttribute('z-class');
891
+ });
892
+
893
+ // -- z-style (dynamic inline styles) ---------------------------
894
+ this._el.querySelectorAll('[z-style]').forEach(el => {
895
+ if (el.closest('[z-pre]')) return;
896
+ const val = this._evalExpr(el.getAttribute('z-style'));
897
+ if (typeof val === 'string') {
898
+ el.style.cssText += ';' + val;
899
+ } else if (val && typeof val === 'object') {
900
+ for (const [prop, v] of Object.entries(val)) {
901
+ el.style[prop] = v;
902
+ }
903
+ }
904
+ el.removeAttribute('z-style');
905
+ });
906
+
907
+ // -- z-html (innerHTML injection) ------------------------------
908
+ this._el.querySelectorAll('[z-html]').forEach(el => {
909
+ if (el.closest('[z-pre]')) return;
910
+ const val = this._evalExpr(el.getAttribute('z-html'));
911
+ el.innerHTML = val != null ? String(val) : '';
912
+ el.removeAttribute('z-html');
913
+ });
914
+
915
+ // -- z-text (safe textContent binding) -------------------------
916
+ this._el.querySelectorAll('[z-text]').forEach(el => {
917
+ if (el.closest('[z-pre]')) return;
918
+ const val = this._evalExpr(el.getAttribute('z-text'));
919
+ el.textContent = val != null ? String(val) : '';
920
+ el.removeAttribute('z-text');
921
+ });
922
+
923
+ // -- z-cloak (remove after render) -----------------------------
924
+ this._el.querySelectorAll('[z-cloak]').forEach(el => {
925
+ el.removeAttribute('z-cloak');
926
+ });
927
+ }
928
+
578
929
  // Programmatic state update (batch-friendly)
930
+ // Passing an empty object forces a re-render (useful for external state changes).
579
931
  setState(partial) {
580
- Object.assign(this.state, partial);
932
+ if (partial && Object.keys(partial).length > 0) {
933
+ Object.assign(this.state, partial);
934
+ } else {
935
+ this._scheduleUpdate();
936
+ }
581
937
  }
582
938
 
583
939
  // Emit custom event up the DOM