zero-query 0.5.2 → 0.7.5

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.
Files changed (58) hide show
  1. package/README.md +12 -10
  2. package/cli/commands/build.js +7 -5
  3. package/cli/commands/bundle.js +286 -8
  4. package/cli/commands/dev/index.js +82 -0
  5. package/cli/commands/dev/logger.js +70 -0
  6. package/cli/commands/dev/overlay.js +366 -0
  7. package/cli/commands/dev/server.js +158 -0
  8. package/cli/commands/dev/validator.js +94 -0
  9. package/cli/commands/dev/watcher.js +147 -0
  10. package/cli/scaffold/favicon.ico +0 -0
  11. package/cli/scaffold/index.html +1 -0
  12. package/cli/scaffold/scripts/app.js +15 -22
  13. package/cli/scaffold/scripts/components/about.js +14 -2
  14. package/cli/scaffold/scripts/components/contacts/contacts.css +0 -7
  15. package/cli/scaffold/scripts/components/contacts/contacts.html +8 -7
  16. package/cli/scaffold/scripts/components/contacts/contacts.js +17 -1
  17. package/cli/scaffold/scripts/components/counter.js +30 -10
  18. package/cli/scaffold/scripts/components/home.js +3 -3
  19. package/cli/scaffold/scripts/components/todos.js +6 -5
  20. package/cli/scaffold/styles/styles.css +1 -0
  21. package/cli/utils.js +111 -6
  22. package/dist/zquery.dist.zip +0 -0
  23. package/dist/zquery.js +2005 -216
  24. package/dist/zquery.min.js +3 -13
  25. package/index.d.ts +149 -1080
  26. package/index.js +18 -7
  27. package/package.json +9 -3
  28. package/src/component.js +186 -45
  29. package/src/core.js +327 -35
  30. package/src/diff.js +280 -0
  31. package/src/errors.js +155 -0
  32. package/src/expression.js +806 -0
  33. package/src/http.js +18 -10
  34. package/src/reactive.js +29 -4
  35. package/src/router.js +59 -6
  36. package/src/ssr.js +224 -0
  37. package/src/store.js +24 -8
  38. package/tests/component.test.js +304 -0
  39. package/tests/core.test.js +726 -0
  40. package/tests/diff.test.js +194 -0
  41. package/tests/errors.test.js +162 -0
  42. package/tests/expression.test.js +334 -0
  43. package/tests/http.test.js +181 -0
  44. package/tests/reactive.test.js +191 -0
  45. package/tests/router.test.js +332 -0
  46. package/tests/store.test.js +253 -0
  47. package/tests/utils.test.js +353 -0
  48. package/types/collection.d.ts +368 -0
  49. package/types/component.d.ts +210 -0
  50. package/types/errors.d.ts +103 -0
  51. package/types/http.d.ts +81 -0
  52. package/types/misc.d.ts +166 -0
  53. package/types/reactive.d.ts +76 -0
  54. package/types/router.d.ts +132 -0
  55. package/types/ssr.d.ts +49 -0
  56. package/types/store.d.ts +107 -0
  57. package/types/utils.d.ts +142 -0
  58. /package/cli/commands/{dev.js → dev.old.js} +0 -0
package/index.js CHANGED
@@ -15,12 +15,15 @@ import { component, mount, mountAll, getInstance, destroy, getRegistry, style }
15
15
  import { createRouter, getRouter } from './src/router.js';
16
16
  import { createStore, getStore } from './src/store.js';
17
17
  import { http } from './src/http.js';
18
+ import { morph } from './src/diff.js';
19
+ import { safeEval } from './src/expression.js';
18
20
  import {
19
21
  debounce, throttle, pipe, once, sleep,
20
22
  escapeHtml, html, trust, uuid, camelCase, kebabCase,
21
23
  deepClone, deepMerge, isEqual, param, parseQuery,
22
24
  storage, session, bus,
23
25
  } from './src/utils.js';
26
+ import { ZQueryError, ErrorCode, onError, reportError } from './src/errors.js';
24
27
 
25
28
 
26
29
  // ---------------------------------------------------------------------------
@@ -28,16 +31,16 @@ import {
28
31
  // ---------------------------------------------------------------------------
29
32
 
30
33
  /**
31
- * Main selector function
34
+ * Main selector function — always returns a ZQueryCollection (like jQuery).
32
35
  *
33
- * $('selector') → single Element (querySelector)
34
- * $('<div>hello</div>') → create element (first created node)
35
- * $(element) → return element as-is
36
+ * $('selector') → ZQueryCollection (querySelectorAll)
37
+ * $('<div>hello</div>') → ZQueryCollection from created elements
38
+ * $(element) → ZQueryCollection wrapping the element
36
39
  * $(fn) → DOMContentLoaded shorthand
37
40
  *
38
41
  * @param {string|Element|NodeList|Function} selector
39
42
  * @param {string|Element} [context]
40
- * @returns {Element|null}
43
+ * @returns {ZQueryCollection}
41
44
  */
42
45
  function $(selector, context) {
43
46
  // $(fn) → DOM ready shorthand
@@ -57,8 +60,6 @@ $.tag = query.tag;
57
60
  Object.defineProperty($, 'name', {
58
61
  value: query.name, writable: true, configurable: true
59
62
  });
60
- $.attr = query.attr;
61
- $.data = query.data;
62
63
  $.children = query.children;
63
64
 
64
65
  // --- Collection selector ---------------------------------------------------
@@ -100,6 +101,8 @@ $.getInstance = getInstance;
100
101
  $.destroy = destroy;
101
102
  $.components = getRegistry;
102
103
  $.style = style;
104
+ $.morph = morph;
105
+ $.safeEval = safeEval;
103
106
 
104
107
  // --- Router ----------------------------------------------------------------
105
108
  $.router = createRouter;
@@ -138,6 +141,11 @@ $.storage = storage;
138
141
  $.session = session;
139
142
  $.bus = bus;
140
143
 
144
+ // --- Error handling --------------------------------------------------------
145
+ $.onError = onError;
146
+ $.ZQueryError = ZQueryError;
147
+ $.ErrorCode = ErrorCode;
148
+
141
149
  // --- Meta ------------------------------------------------------------------
142
150
  $.version = '__VERSION__';
143
151
  $.meta = {}; // populated at build time by CLI bundler
@@ -169,9 +177,12 @@ export {
169
177
  queryAll,
170
178
  reactive, Signal, signal, computed, effect,
171
179
  component, mount, mountAll, getInstance, destroy, getRegistry, style,
180
+ morph,
181
+ safeEval,
172
182
  createRouter, getRouter,
173
183
  createStore, getStore,
174
184
  http,
185
+ ZQueryError, ErrorCode, onError, reportError,
175
186
  debounce, throttle, pipe, once, sleep,
176
187
  escapeHtml, html, trust, uuid, camelCase, kebabCase,
177
188
  deepClone, deepMerge, isEqual, param, parseQuery,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-query",
3
- "version": "0.5.2",
3
+ "version": "0.7.5",
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",
@@ -9,8 +9,10 @@
9
9
  },
10
10
  "files": [
11
11
  "src",
12
+ "tests",
12
13
  "dist",
13
14
  "cli",
15
+ "types",
14
16
  "index.js",
15
17
  "index.d.ts",
16
18
  "LICENSE",
@@ -21,7 +23,9 @@
21
23
  "dev": "node cli/index.js dev zquery-website",
22
24
  "dev-lib": "node cli/index.js build --watch",
23
25
  "bundle": "node cli/index.js bundle",
24
- "bundle:app": "node cli/index.js bundle zquery-website --minimal"
26
+ "bundle:app": "node cli/index.js bundle zquery-website --minimal",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest"
25
29
  },
26
30
  "keywords": [
27
31
  "dom",
@@ -50,7 +54,9 @@
50
54
  "publishConfig": {
51
55
  "access": "public"
52
56
  },
53
- "dependencies": {
57
+ "devDependencies": {
58
+ "jsdom": "^28.1.0",
59
+ "vitest": "^4.0.18",
54
60
  "zero-http": "^0.2.3"
55
61
  }
56
62
  }
package/src/component.js CHANGED
@@ -20,6 +20,9 @@
20
20
  */
21
21
 
22
22
  import { reactive } from './reactive.js';
23
+ import { morph } from './diff.js';
24
+ import { safeEval } from './expression.js';
25
+ import { reportError, ErrorCode, ZQueryError } from './errors.js';
23
26
 
24
27
  // ---------------------------------------------------------------------------
25
28
  // Component registry & external resource cache
@@ -195,10 +198,27 @@ class Component {
195
198
  this._destroyed = false;
196
199
  this._updateQueued = false;
197
200
  this._listeners = [];
201
+ this._watchCleanups = [];
198
202
 
199
203
  // Refs map
200
204
  this.refs = {};
201
205
 
206
+ // Capture slot content before first render replaces it
207
+ this._slotContent = {};
208
+ const defaultSlotNodes = [];
209
+ [...el.childNodes].forEach(node => {
210
+ if (node.nodeType === 1 && node.hasAttribute('slot')) {
211
+ const slotName = node.getAttribute('slot');
212
+ if (!this._slotContent[slotName]) this._slotContent[slotName] = '';
213
+ this._slotContent[slotName] += node.outerHTML;
214
+ } else if (node.nodeType === 1 || (node.nodeType === 3 && node.textContent.trim())) {
215
+ defaultSlotNodes.push(node.nodeType === 1 ? node.outerHTML : node.textContent);
216
+ }
217
+ });
218
+ if (defaultSlotNodes.length) {
219
+ this._slotContent['default'] = defaultSlotNodes.join('');
220
+ }
221
+
202
222
  // Props (read-only from parent)
203
223
  this.props = Object.freeze({ ...props });
204
224
 
@@ -207,10 +227,25 @@ class Component {
207
227
  ? definition.state()
208
228
  : { ...(definition.state || {}) };
209
229
 
210
- this.state = reactive(initialState, () => {
211
- if (!this._destroyed) this._scheduleUpdate();
230
+ this.state = reactive(initialState, (key, value, old) => {
231
+ if (!this._destroyed) {
232
+ // Run watchers for the changed key
233
+ this._runWatchers(key, value, old);
234
+ this._scheduleUpdate();
235
+ }
212
236
  });
213
237
 
238
+ // Computed properties — lazy getters derived from state
239
+ this.computed = {};
240
+ if (definition.computed) {
241
+ for (const [name, fn] of Object.entries(definition.computed)) {
242
+ Object.defineProperty(this.computed, name, {
243
+ get: () => fn.call(this, this.state.__raw || this.state),
244
+ enumerable: true
245
+ });
246
+ }
247
+ }
248
+
214
249
  // Bind all user methods to this instance
215
250
  for (const [key, val] of Object.entries(definition)) {
216
251
  if (typeof val === 'function' && !_reservedKeys.has(key)) {
@@ -219,7 +254,36 @@ class Component {
219
254
  }
220
255
 
221
256
  // Init lifecycle
222
- if (definition.init) definition.init.call(this);
257
+ if (definition.init) {
258
+ try { definition.init.call(this); }
259
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${definition._name}" init() threw`, { component: definition._name }, err); }
260
+ }
261
+
262
+ // Set up watchers after init so initial state is ready
263
+ if (definition.watch) {
264
+ this._prevWatchValues = {};
265
+ for (const key of Object.keys(definition.watch)) {
266
+ this._prevWatchValues[key] = _getPath(this.state.__raw || this.state, key);
267
+ }
268
+ }
269
+ }
270
+
271
+ // Run registered watchers for a changed key
272
+ _runWatchers(changedKey, value, old) {
273
+ const watchers = this._def.watch;
274
+ if (!watchers) return;
275
+ for (const [key, handler] of Object.entries(watchers)) {
276
+ // Match exact key or parent key (e.g. watcher on 'user' fires when 'user.name' changes)
277
+ if (changedKey === key || key.startsWith(changedKey + '.') || changedKey.startsWith(key + '.') || changedKey === key) {
278
+ const currentVal = _getPath(this.state.__raw || this.state, key);
279
+ const prevVal = this._prevWatchValues?.[key];
280
+ if (currentVal !== prevVal) {
281
+ const fn = typeof handler === 'function' ? handler : handler.handler;
282
+ if (typeof fn === 'function') fn.call(this, currentVal, prevVal);
283
+ if (this._prevWatchValues) this._prevWatchValues[key] = currentVal;
284
+ }
285
+ }
286
+ }
223
287
  }
224
288
 
225
289
  // Schedule a batched DOM update (microtask)
@@ -317,11 +381,14 @@ class Component {
317
381
  if (def.styleUrl && !def._styleLoaded) {
318
382
  const su = def.styleUrl;
319
383
  if (typeof su === 'string') {
320
- def._externalStyles = await _fetchResource(_resolveUrl(su, base));
384
+ const resolved = _resolveUrl(su, base);
385
+ def._externalStyles = await _fetchResource(resolved);
386
+ def._resolvedStyleUrls = [resolved];
321
387
  } else if (Array.isArray(su)) {
322
388
  const urls = su.map(u => _resolveUrl(u, base));
323
389
  const results = await Promise.all(urls.map(u => _fetchResource(u)));
324
390
  def._externalStyles = results.join('\n');
391
+ def._resolvedStyleUrls = urls;
325
392
  }
326
393
  def._styleLoaded = true;
327
394
  }
@@ -394,17 +461,29 @@ class Component {
394
461
  // Then do global {{expression}} interpolation on the remaining content
395
462
  html = html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
396
463
  try {
397
- return new Function('state', 'props', '$', `with(state){return ${expr.trim()}}`)(
464
+ const result = safeEval(expr.trim(), [
398
465
  this.state.__raw || this.state,
399
- this.props,
400
- typeof window !== 'undefined' ? window.$ : undefined
401
- );
466
+ { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
467
+ ]);
468
+ return result != null ? result : '';
402
469
  } catch { return ''; }
403
470
  });
404
471
  } else {
405
472
  html = '';
406
473
  }
407
474
 
475
+ // -- Slot distribution ----------------------------------------
476
+ // Replace <slot> elements with captured slot content from parent.
477
+ // <slot> → default slot content
478
+ // <slot name="header"> → named slot content
479
+ // Fallback content between <slot>...</slot> used when no content provided.
480
+ if (html.includes('<slot')) {
481
+ html = html.replace(/<slot(?:\s+name="([^"]*)")?\s*(?:\/>|>([\s\S]*?)<\/slot>)/g, (_, name, fallback) => {
482
+ const slotName = name || 'default';
483
+ return this._slotContent[slotName] || fallback || '';
484
+ });
485
+ }
486
+
408
487
  // Combine inline styles + external styles
409
488
  const combinedStyles = [
410
489
  this._def.styles || '',
@@ -415,33 +494,52 @@ class Component {
415
494
  if (!this._mounted && combinedStyles) {
416
495
  const scopeAttr = `z-s${this._uid}`;
417
496
  this._el.setAttribute(scopeAttr, '');
418
- const scoped = combinedStyles.replace(/([^{}]+)\{/g, (match, selector) => {
497
+ let inAtBlock = 0;
498
+ const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
499
+ if (match === '}') {
500
+ if (inAtBlock > 0) inAtBlock--;
501
+ return match;
502
+ }
503
+ const trimmed = selector.trim();
504
+ // Don't scope @-rules (@media, @keyframes, @supports, @container, @layer, @font-face, etc.)
505
+ if (trimmed.startsWith('@')) {
506
+ inAtBlock++;
507
+ return match;
508
+ }
509
+ // Don't scope keyframe stops (from, to, 0%, 50%, etc.)
510
+ if (inAtBlock > 0 && /^[\d%\s,fromto]+$/.test(trimmed.replace(/\s/g, ''))) {
511
+ return match;
512
+ }
419
513
  return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
420
514
  });
421
515
  const styleEl = document.createElement('style');
422
516
  styleEl.textContent = scoped;
423
517
  styleEl.setAttribute('data-zq-component', this._def._name || '');
518
+ styleEl.setAttribute('data-zq-scope', scopeAttr);
519
+ if (this._def._resolvedStyleUrls) {
520
+ styleEl.setAttribute('data-zq-style-urls', this._def._resolvedStyleUrls.join(' '));
521
+ if (this._def.styles) {
522
+ styleEl.setAttribute('data-zq-inline', this._def.styles);
523
+ }
524
+ }
424
525
  document.head.appendChild(styleEl);
425
526
  this._styleEl = styleEl;
426
527
  }
427
528
 
428
529
  // -- Focus preservation ----------------------------------------
429
- // Before replacing innerHTML, save focus state so we can restore
430
- // cursor position after the DOM is rebuilt. Works for any focused
431
- // input/textarea/select inside the component, not only z-model.
530
+ // DOM morphing preserves unchanged nodes naturally, but we still
531
+ // track focus for cases where the focused element's subtree changes.
432
532
  let _focusInfo = null;
433
533
  const _active = document.activeElement;
434
534
  if (_active && this._el.contains(_active)) {
435
535
  const modelKey = _active.getAttribute?.('z-model');
436
536
  const refKey = _active.getAttribute?.('z-ref');
437
- // Build a selector that can locate the same element after re-render
438
537
  let selector = null;
439
538
  if (modelKey) {
440
539
  selector = `[z-model="${modelKey}"]`;
441
540
  } else if (refKey) {
442
541
  selector = `[z-ref="${refKey}"]`;
443
542
  } else {
444
- // Fallback: match by tag + type + name + placeholder combination
445
543
  const tag = _active.tagName.toLowerCase();
446
544
  if (tag === 'input' || tag === 'textarea' || tag === 'select') {
447
545
  let s = tag;
@@ -461,8 +559,13 @@ class Component {
461
559
  }
462
560
  }
463
561
 
464
- // Update DOM
465
- this._el.innerHTML = html;
562
+ // Update DOM via morphing (diffing) — preserves unchanged nodes
563
+ // First render uses innerHTML for speed; subsequent renders morph.
564
+ if (!this._mounted) {
565
+ this._el.innerHTML = html;
566
+ } else {
567
+ morph(this._el, html);
568
+ }
466
569
 
467
570
  // Process structural & attribute directives
468
571
  this._processDirectives();
@@ -472,10 +575,10 @@ class Component {
472
575
  this._bindRefs();
473
576
  this._bindModels();
474
577
 
475
- // Restore focus after re-render
578
+ // Restore focus if the morph replaced the focused element
476
579
  if (_focusInfo) {
477
580
  const el = this._el.querySelector(_focusInfo.selector);
478
- if (el) {
581
+ if (el && el !== document.activeElement) {
479
582
  el.focus();
480
583
  try {
481
584
  if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
@@ -490,9 +593,15 @@ class Component {
490
593
 
491
594
  if (!this._mounted) {
492
595
  this._mounted = true;
493
- if (this._def.mounted) this._def.mounted.call(this);
596
+ if (this._def.mounted) {
597
+ try { this._def.mounted.call(this); }
598
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" mounted() threw`, { component: this._def._name }, err); }
599
+ }
494
600
  } else {
495
- if (this._def.updated) this._def.updated.call(this);
601
+ if (this._def.updated) {
602
+ try { this._def.updated.call(this); }
603
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" updated() threw`, { component: this._def._name }, err); }
604
+ }
496
605
  }
497
606
  }
498
607
 
@@ -701,18 +810,13 @@ class Component {
701
810
  }
702
811
 
703
812
  // ---------------------------------------------------------------------------
704
- // Expression evaluator — runs expr in component context (state, props, refs)
813
+ // Expression evaluator — CSP-safe parser (no eval / new Function)
705
814
  // ---------------------------------------------------------------------------
706
815
  _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; }
816
+ return safeEval(expr, [
817
+ this.state.__raw || this.state,
818
+ { props: this.props, refs: this.refs, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
819
+ ]);
716
820
  }
717
821
 
718
822
  // ---------------------------------------------------------------------------
@@ -774,13 +878,15 @@ class Component {
774
878
  const evalReplace = (str, item, index) =>
775
879
  str.replace(/\{\{(.+?)\}\}/g, (_, inner) => {
776
880
  try {
777
- return new Function(itemVar, indexVar, 'state', 'props', '$',
778
- `with(state){return (${inner.trim()})}`)(
779
- item, index,
881
+ const loopScope = {};
882
+ loopScope[itemVar] = item;
883
+ loopScope[indexVar] = index;
884
+ const result = safeEval(inner.trim(), [
885
+ loopScope,
780
886
  this.state.__raw || this.state,
781
- this.props,
782
- typeof window !== 'undefined' ? window.$ : undefined
783
- );
887
+ { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
888
+ ]);
889
+ return result != null ? result : '';
784
890
  } catch { return ''; }
785
891
  });
786
892
 
@@ -945,7 +1051,10 @@ class Component {
945
1051
  destroy() {
946
1052
  if (this._destroyed) return;
947
1053
  this._destroyed = true;
948
- if (this._def.destroyed) this._def.destroyed.call(this);
1054
+ if (this._def.destroyed) {
1055
+ try { this._def.destroyed.call(this); }
1056
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" destroyed() threw`, { component: this._def._name }, err); }
1057
+ }
949
1058
  this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
950
1059
  this._listeners = [];
951
1060
  if (this._styleEl) this._styleEl.remove();
@@ -958,7 +1067,8 @@ class Component {
958
1067
  // Reserved definition keys (not user methods)
959
1068
  const _reservedKeys = new Set([
960
1069
  'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
961
- 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base'
1070
+ 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base',
1071
+ 'computed', 'watch'
962
1072
  ]);
963
1073
 
964
1074
 
@@ -972,8 +1082,11 @@ const _reservedKeys = new Set([
972
1082
  * @param {object} definition — component definition
973
1083
  */
974
1084
  export function component(name, definition) {
1085
+ if (!name || typeof name !== 'string') {
1086
+ throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, 'Component name must be a non-empty string');
1087
+ }
975
1088
  if (!name.includes('-')) {
976
- throw new Error(`zQuery: Component name "${name}" must contain a hyphen (Web Component convention)`);
1089
+ throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, `Component name "${name}" must contain a hyphen (Web Component convention)`);
977
1090
  }
978
1091
  definition._name = name;
979
1092
 
@@ -998,10 +1111,10 @@ export function component(name, definition) {
998
1111
  */
999
1112
  export function mount(target, componentName, props = {}) {
1000
1113
  const el = typeof target === 'string' ? document.querySelector(target) : target;
1001
- if (!el) throw new Error(`zQuery: Mount target "${target}" not found`);
1114
+ if (!el) throw new ZQueryError(ErrorCode.COMP_MOUNT_TARGET, `Mount target "${target}" not found`, { target });
1002
1115
 
1003
1116
  const def = _registry.get(componentName);
1004
- if (!def) throw new Error(`zQuery: Component "${componentName}" not registered`);
1117
+ if (!def) throw new ZQueryError(ErrorCode.COMP_NOT_FOUND, `Component "${componentName}" not registered`, { component: componentName });
1005
1118
 
1006
1119
  // Destroy existing instance
1007
1120
  if (_instances.has(el)) _instances.get(el).destroy();
@@ -1024,12 +1137,40 @@ export function mountAll(root = document.body) {
1024
1137
 
1025
1138
  // Extract props from attributes
1026
1139
  const props = {};
1140
+
1141
+ // Find parent component instance for evaluating dynamic prop expressions
1142
+ let parentInstance = null;
1143
+ let ancestor = tag.parentElement;
1144
+ while (ancestor) {
1145
+ if (_instances.has(ancestor)) {
1146
+ parentInstance = _instances.get(ancestor);
1147
+ break;
1148
+ }
1149
+ ancestor = ancestor.parentElement;
1150
+ }
1151
+
1027
1152
  [...tag.attributes].forEach(attr => {
1028
- if (!attr.name.startsWith('@') && !attr.name.startsWith('z-')) {
1029
- // Try JSON parse for objects/arrays
1030
- try { props[attr.name] = JSON.parse(attr.value); }
1031
- catch { props[attr.name] = attr.value; }
1153
+ if (attr.name.startsWith('@') || attr.name.startsWith('z-')) return;
1154
+
1155
+ // Dynamic prop: :propName="expression" evaluate in parent context
1156
+ if (attr.name.startsWith(':')) {
1157
+ const propName = attr.name.slice(1);
1158
+ if (parentInstance) {
1159
+ props[propName] = safeEval(attr.value, [
1160
+ parentInstance.state.__raw || parentInstance.state,
1161
+ { props: parentInstance.props, refs: parentInstance.refs, computed: parentInstance.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
1162
+ ]);
1163
+ } else {
1164
+ // No parent — try JSON parse
1165
+ try { props[propName] = JSON.parse(attr.value); }
1166
+ catch { props[propName] = attr.value; }
1167
+ }
1168
+ return;
1032
1169
  }
1170
+
1171
+ // Static prop
1172
+ try { props[attr.name] = JSON.parse(attr.value); }
1173
+ catch { props[attr.name] = attr.value; }
1033
1174
  });
1034
1175
 
1035
1176
  const instance = new Component(tag, def, props);