zero-query 0.4.9 → 0.6.3

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/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)
@@ -394,17 +458,29 @@ class Component {
394
458
  // Then do global {{expression}} interpolation on the remaining content
395
459
  html = html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
396
460
  try {
397
- return new Function('state', 'props', '$', `with(state){return ${expr.trim()}}`)(
461
+ const result = safeEval(expr.trim(), [
398
462
  this.state.__raw || this.state,
399
- this.props,
400
- typeof window !== 'undefined' ? window.$ : undefined
401
- );
463
+ { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
464
+ ]);
465
+ return result != null ? result : '';
402
466
  } catch { return ''; }
403
467
  });
404
468
  } else {
405
469
  html = '';
406
470
  }
407
471
 
472
+ // -- Slot distribution ----------------------------------------
473
+ // Replace <slot> elements with captured slot content from parent.
474
+ // <slot> → default slot content
475
+ // <slot name="header"> → named slot content
476
+ // Fallback content between <slot>...</slot> used when no content provided.
477
+ if (html.includes('<slot')) {
478
+ html = html.replace(/<slot(?:\s+name="([^"]*)")?\s*(?:\/>|>([\s\S]*?)<\/slot>)/g, (_, name, fallback) => {
479
+ const slotName = name || 'default';
480
+ return this._slotContent[slotName] || fallback || '';
481
+ });
482
+ }
483
+
408
484
  // Combine inline styles + external styles
409
485
  const combinedStyles = [
410
486
  this._def.styles || '',
@@ -415,7 +491,22 @@ class Component {
415
491
  if (!this._mounted && combinedStyles) {
416
492
  const scopeAttr = `z-s${this._uid}`;
417
493
  this._el.setAttribute(scopeAttr, '');
418
- const scoped = combinedStyles.replace(/([^{}]+)\{/g, (match, selector) => {
494
+ let inAtBlock = 0;
495
+ const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
496
+ if (match === '}') {
497
+ if (inAtBlock > 0) inAtBlock--;
498
+ return match;
499
+ }
500
+ const trimmed = selector.trim();
501
+ // Don't scope @-rules (@media, @keyframes, @supports, @container, @layer, @font-face, etc.)
502
+ if (trimmed.startsWith('@')) {
503
+ inAtBlock++;
504
+ return match;
505
+ }
506
+ // Don't scope keyframe stops (from, to, 0%, 50%, etc.)
507
+ if (inAtBlock > 0 && /^[\d%\s,fromto]+$/.test(trimmed.replace(/\s/g, ''))) {
508
+ return match;
509
+ }
419
510
  return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
420
511
  });
421
512
  const styleEl = document.createElement('style');
@@ -426,22 +517,19 @@ class Component {
426
517
  }
427
518
 
428
519
  // -- 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.
520
+ // DOM morphing preserves unchanged nodes naturally, but we still
521
+ // track focus for cases where the focused element's subtree changes.
432
522
  let _focusInfo = null;
433
523
  const _active = document.activeElement;
434
524
  if (_active && this._el.contains(_active)) {
435
525
  const modelKey = _active.getAttribute?.('z-model');
436
526
  const refKey = _active.getAttribute?.('z-ref');
437
- // Build a selector that can locate the same element after re-render
438
527
  let selector = null;
439
528
  if (modelKey) {
440
529
  selector = `[z-model="${modelKey}"]`;
441
530
  } else if (refKey) {
442
531
  selector = `[z-ref="${refKey}"]`;
443
532
  } else {
444
- // Fallback: match by tag + type + name + placeholder combination
445
533
  const tag = _active.tagName.toLowerCase();
446
534
  if (tag === 'input' || tag === 'textarea' || tag === 'select') {
447
535
  let s = tag;
@@ -461,8 +549,13 @@ class Component {
461
549
  }
462
550
  }
463
551
 
464
- // Update DOM
465
- this._el.innerHTML = html;
552
+ // Update DOM via morphing (diffing) — preserves unchanged nodes
553
+ // First render uses innerHTML for speed; subsequent renders morph.
554
+ if (!this._mounted) {
555
+ this._el.innerHTML = html;
556
+ } else {
557
+ morph(this._el, html);
558
+ }
466
559
 
467
560
  // Process structural & attribute directives
468
561
  this._processDirectives();
@@ -472,10 +565,10 @@ class Component {
472
565
  this._bindRefs();
473
566
  this._bindModels();
474
567
 
475
- // Restore focus after re-render
568
+ // Restore focus if the morph replaced the focused element
476
569
  if (_focusInfo) {
477
570
  const el = this._el.querySelector(_focusInfo.selector);
478
- if (el) {
571
+ if (el && el !== document.activeElement) {
479
572
  el.focus();
480
573
  try {
481
574
  if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
@@ -490,9 +583,15 @@ class Component {
490
583
 
491
584
  if (!this._mounted) {
492
585
  this._mounted = true;
493
- if (this._def.mounted) this._def.mounted.call(this);
586
+ if (this._def.mounted) {
587
+ try { this._def.mounted.call(this); }
588
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" mounted() threw`, { component: this._def._name }, err); }
589
+ }
494
590
  } else {
495
- if (this._def.updated) this._def.updated.call(this);
591
+ if (this._def.updated) {
592
+ try { this._def.updated.call(this); }
593
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" updated() threw`, { component: this._def._name }, err); }
594
+ }
496
595
  }
497
596
  }
498
597
 
@@ -701,18 +800,13 @@ class Component {
701
800
  }
702
801
 
703
802
  // ---------------------------------------------------------------------------
704
- // Expression evaluator — runs expr in component context (state, props, refs)
803
+ // Expression evaluator — CSP-safe parser (no eval / new Function)
705
804
  // ---------------------------------------------------------------------------
706
805
  _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; }
806
+ return safeEval(expr, [
807
+ this.state.__raw || this.state,
808
+ { props: this.props, refs: this.refs, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
809
+ ]);
716
810
  }
717
811
 
718
812
  // ---------------------------------------------------------------------------
@@ -774,13 +868,15 @@ class Component {
774
868
  const evalReplace = (str, item, index) =>
775
869
  str.replace(/\{\{(.+?)\}\}/g, (_, inner) => {
776
870
  try {
777
- return new Function(itemVar, indexVar, 'state', 'props', '$',
778
- `with(state){return (${inner.trim()})}`)(
779
- item, index,
871
+ const loopScope = {};
872
+ loopScope[itemVar] = item;
873
+ loopScope[indexVar] = index;
874
+ const result = safeEval(inner.trim(), [
875
+ loopScope,
780
876
  this.state.__raw || this.state,
781
- this.props,
782
- typeof window !== 'undefined' ? window.$ : undefined
783
- );
877
+ { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
878
+ ]);
879
+ return result != null ? result : '';
784
880
  } catch { return ''; }
785
881
  });
786
882
 
@@ -945,7 +1041,10 @@ class Component {
945
1041
  destroy() {
946
1042
  if (this._destroyed) return;
947
1043
  this._destroyed = true;
948
- if (this._def.destroyed) this._def.destroyed.call(this);
1044
+ if (this._def.destroyed) {
1045
+ try { this._def.destroyed.call(this); }
1046
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" destroyed() threw`, { component: this._def._name }, err); }
1047
+ }
949
1048
  this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
950
1049
  this._listeners = [];
951
1050
  if (this._styleEl) this._styleEl.remove();
@@ -958,7 +1057,8 @@ class Component {
958
1057
  // Reserved definition keys (not user methods)
959
1058
  const _reservedKeys = new Set([
960
1059
  'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
961
- 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base'
1060
+ 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base',
1061
+ 'computed', 'watch'
962
1062
  ]);
963
1063
 
964
1064
 
@@ -972,8 +1072,11 @@ const _reservedKeys = new Set([
972
1072
  * @param {object} definition — component definition
973
1073
  */
974
1074
  export function component(name, definition) {
1075
+ if (!name || typeof name !== 'string') {
1076
+ throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, 'Component name must be a non-empty string');
1077
+ }
975
1078
  if (!name.includes('-')) {
976
- throw new Error(`zQuery: Component name "${name}" must contain a hyphen (Web Component convention)`);
1079
+ throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, `Component name "${name}" must contain a hyphen (Web Component convention)`);
977
1080
  }
978
1081
  definition._name = name;
979
1082
 
@@ -998,10 +1101,10 @@ export function component(name, definition) {
998
1101
  */
999
1102
  export function mount(target, componentName, props = {}) {
1000
1103
  const el = typeof target === 'string' ? document.querySelector(target) : target;
1001
- if (!el) throw new Error(`zQuery: Mount target "${target}" not found`);
1104
+ if (!el) throw new ZQueryError(ErrorCode.COMP_MOUNT_TARGET, `Mount target "${target}" not found`, { target });
1002
1105
 
1003
1106
  const def = _registry.get(componentName);
1004
- if (!def) throw new Error(`zQuery: Component "${componentName}" not registered`);
1107
+ if (!def) throw new ZQueryError(ErrorCode.COMP_NOT_FOUND, `Component "${componentName}" not registered`, { component: componentName });
1005
1108
 
1006
1109
  // Destroy existing instance
1007
1110
  if (_instances.has(el)) _instances.get(el).destroy();
@@ -1024,12 +1127,40 @@ export function mountAll(root = document.body) {
1024
1127
 
1025
1128
  // Extract props from attributes
1026
1129
  const props = {};
1130
+
1131
+ // Find parent component instance for evaluating dynamic prop expressions
1132
+ let parentInstance = null;
1133
+ let ancestor = tag.parentElement;
1134
+ while (ancestor) {
1135
+ if (_instances.has(ancestor)) {
1136
+ parentInstance = _instances.get(ancestor);
1137
+ break;
1138
+ }
1139
+ ancestor = ancestor.parentElement;
1140
+ }
1141
+
1027
1142
  [...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; }
1143
+ if (attr.name.startsWith('@') || attr.name.startsWith('z-')) return;
1144
+
1145
+ // Dynamic prop: :propName="expression" evaluate in parent context
1146
+ if (attr.name.startsWith(':')) {
1147
+ const propName = attr.name.slice(1);
1148
+ if (parentInstance) {
1149
+ props[propName] = safeEval(attr.value, [
1150
+ parentInstance.state.__raw || parentInstance.state,
1151
+ { props: parentInstance.props, refs: parentInstance.refs, computed: parentInstance.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
1152
+ ]);
1153
+ } else {
1154
+ // No parent — try JSON parse
1155
+ try { props[propName] = JSON.parse(attr.value); }
1156
+ catch { props[propName] = attr.value; }
1157
+ }
1158
+ return;
1032
1159
  }
1160
+
1161
+ // Static prop
1162
+ try { props[attr.name] = JSON.parse(attr.value); }
1163
+ catch { props[attr.name] = attr.value; }
1033
1164
  });
1034
1165
 
1035
1166
  const instance = new Component(tag, def, props);
package/src/core.js CHANGED
@@ -26,6 +26,11 @@ export class ZQueryCollection {
26
26
  return this.elements.map((el, i) => fn.call(el, i, el));
27
27
  }
28
28
 
29
+ forEach(fn) {
30
+ this.elements.forEach((el, i) => fn(el, i, this.elements));
31
+ return this;
32
+ }
33
+
29
34
  first() { return this.elements[0] || null; }
30
35
  last() { return this.elements[this.length - 1] || null; }
31
36
  eq(i) { return new ZQueryCollection(this.elements[i] ? [this.elements[i]] : []); }
@@ -384,42 +389,40 @@ function createFragment(html) {
384
389
 
385
390
 
386
391
  // ---------------------------------------------------------------------------
387
- // $() — main selector / creator function (returns single element for CSS selectors)
392
+ // $() — main selector / creator (returns ZQueryCollection, like jQuery)
388
393
  // ---------------------------------------------------------------------------
389
394
  export function query(selector, context) {
390
395
  // null / undefined
391
- if (!selector) return null;
396
+ if (!selector) return new ZQueryCollection([]);
392
397
 
393
- // Already a collection — return first element
394
- if (selector instanceof ZQueryCollection) return selector.first();
398
+ // Already a collection — return as-is
399
+ if (selector instanceof ZQueryCollection) return selector;
395
400
 
396
- // DOM element or Window — return as-is
401
+ // DOM element or Window — wrap in collection
397
402
  if (selector instanceof Node || selector === window) {
398
- return selector;
403
+ return new ZQueryCollection([selector]);
399
404
  }
400
405
 
401
- // NodeList / HTMLCollection / Array — return first element
406
+ // NodeList / HTMLCollection / Array — wrap in collection
402
407
  if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
403
- const arr = Array.from(selector);
404
- return arr[0] || null;
408
+ return new ZQueryCollection(Array.from(selector));
405
409
  }
406
410
 
407
- // HTML string → create elements, return first
411
+ // HTML string → create elements, wrap in collection
408
412
  if (typeof selector === 'string' && selector.trim().startsWith('<')) {
409
413
  const fragment = createFragment(selector);
410
- const els = [...fragment.childNodes].filter(n => n.nodeType === 1);
411
- return els[0] || null;
414
+ return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
412
415
  }
413
416
 
414
- // CSS selector string → querySelector (single element)
417
+ // CSS selector string → querySelectorAll (collection)
415
418
  if (typeof selector === 'string') {
416
419
  const root = context
417
420
  ? (typeof context === 'string' ? document.querySelector(context) : context)
418
421
  : document;
419
- return root.querySelector(selector);
422
+ return new ZQueryCollection([...root.querySelectorAll(selector)]);
420
423
  }
421
424
 
422
- return null;
425
+ return new ZQueryCollection([]);
423
426
  }
424
427
 
425
428
 
@@ -466,11 +469,15 @@ export function queryAll(selector, context) {
466
469
  // ---------------------------------------------------------------------------
467
470
  query.id = (id) => document.getElementById(id);
468
471
  query.class = (name) => document.querySelector(`.${name}`);
469
- query.classes = (name) => Array.from(document.getElementsByClassName(name));
470
- query.tag = (name) => Array.from(document.getElementsByTagName(name));
472
+ query.classes = (name) => new ZQueryCollection(Array.from(document.getElementsByClassName(name)));
473
+ query.tag = (name) => new ZQueryCollection(Array.from(document.getElementsByTagName(name)));
474
+ Object.defineProperty(query, 'name', {
475
+ value: (name) => new ZQueryCollection(Array.from(document.getElementsByName(name))),
476
+ writable: true, configurable: true
477
+ });
471
478
  query.children = (parentId) => {
472
479
  const p = document.getElementById(parentId);
473
- return p ? Array.from(p.children) : [];
480
+ return new ZQueryCollection(p ? Array.from(p.children) : []);
474
481
  };
475
482
 
476
483
  // Create element shorthand