zero-query 0.9.0 → 0.9.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.
package/index.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Lightweight modern frontend library — jQuery-like selectors, reactive
5
5
  * components, SPA router, state management, HTTP client & utilities.
6
6
  *
7
- * @version 0.9.0
7
+ * @version 0.9.5
8
8
  * @license MIT
9
9
  * @see https://z-query.com/docs
10
10
  */
@@ -74,6 +74,7 @@ export {
74
74
  once,
75
75
  sleep,
76
76
  escapeHtml,
77
+ stripHtml,
77
78
  html,
78
79
  TrustedHTML,
79
80
  trust,
@@ -90,6 +91,23 @@ export {
90
91
  session,
91
92
  EventBus,
92
93
  bus,
94
+ range,
95
+ unique,
96
+ chunk,
97
+ groupBy,
98
+ pick,
99
+ omit,
100
+ getPath,
101
+ setPath,
102
+ isEmpty,
103
+ capitalize,
104
+ truncate,
105
+ clamp,
106
+ MemoizedFunction,
107
+ memoize,
108
+ RetryOptions,
109
+ retry,
110
+ timeout,
93
111
  } from './types/utils';
94
112
 
95
113
  export {
@@ -122,11 +140,15 @@ import type { createStore, getStore } from './types/store';
122
140
  import type { HttpClient } from './types/http';
123
141
  import type {
124
142
  debounce, throttle, pipe, once, sleep,
125
- escapeHtml, html, trust, uuid, camelCase, kebabCase,
143
+ escapeHtml, stripHtml, html, trust, uuid, camelCase, kebabCase,
126
144
  deepClone, deepMerge, isEqual, param, parseQuery,
127
145
  StorageWrapper, EventBus,
146
+ range, unique, chunk, groupBy,
147
+ pick, omit, getPath, setPath, isEmpty,
148
+ capitalize, truncate, clamp,
149
+ MemoizedFunction, memoize, RetryOptions, retry, timeout,
128
150
  } from './types/utils';
129
- import type { onError, ZQueryError, ErrorCode } from './types/errors';
151
+ import type { onError, ZQueryError, ErrorCode, guardCallback, validate } from './types/errors';
130
152
  import type { morph, morphElement, safeEval } from './types/misc';
131
153
 
132
154
  /**
@@ -169,6 +191,10 @@ interface ZQueryStatic {
169
191
  name(name: string): ZQueryCollection;
170
192
  /** Children of `#parentId` as `ZQueryCollection`. */
171
193
  children(parentId: string): ZQueryCollection;
194
+ /** `document.querySelector(selector)` — raw Element or null. */
195
+ qs(selector: string, context?: Element | Document): Element | null;
196
+ /** `document.querySelectorAll(selector)` — as a real `Array<Element>`. */
197
+ qsa(selector: string, context?: Element | Document): Element[];
172
198
 
173
199
  // -- Static helpers ------------------------------------------------------
174
200
  /**
@@ -245,6 +271,10 @@ interface ZQueryStatic {
245
271
  ZQueryError: typeof ZQueryError;
246
272
  /** Frozen map of all error code constants. */
247
273
  ErrorCode: typeof ErrorCode;
274
+ /** Wrap a callback so thrown errors are caught and reported via the global handler. */
275
+ guardCallback: typeof guardCallback;
276
+ /** Validate a required value is defined and of the expected type. */
277
+ validate: typeof validate;
248
278
 
249
279
  // -- Utilities -----------------------------------------------------------
250
280
  debounce: typeof debounce;
@@ -254,6 +284,7 @@ interface ZQueryStatic {
254
284
  sleep: typeof sleep;
255
285
 
256
286
  escapeHtml: typeof escapeHtml;
287
+ stripHtml: typeof stripHtml;
257
288
  html: typeof html;
258
289
  trust: typeof trust;
259
290
  uuid: typeof uuid;
@@ -269,8 +300,25 @@ interface ZQueryStatic {
269
300
 
270
301
  storage: StorageWrapper;
271
302
  session: StorageWrapper;
303
+ EventBus: typeof EventBus;
272
304
  bus: EventBus;
273
305
 
306
+ range: typeof range;
307
+ unique: typeof unique;
308
+ chunk: typeof chunk;
309
+ groupBy: typeof groupBy;
310
+ pick: typeof pick;
311
+ omit: typeof omit;
312
+ getPath: typeof getPath;
313
+ setPath: typeof setPath;
314
+ isEmpty: typeof isEmpty;
315
+ capitalize: typeof capitalize;
316
+ truncate: typeof truncate;
317
+ clamp: typeof clamp;
318
+ memoize: typeof memoize;
319
+ retry: typeof retry;
320
+ timeout: typeof timeout;
321
+
274
322
  // -- Meta ----------------------------------------------------------------
275
323
  /** Library version string. */
276
324
  version: string;
package/index.js CHANGED
@@ -19,9 +19,13 @@ import { morph, morphElement } from './src/diff.js';
19
19
  import { safeEval } from './src/expression.js';
20
20
  import {
21
21
  debounce, throttle, pipe, once, sleep,
22
- escapeHtml, html, trust, uuid, camelCase, kebabCase,
22
+ escapeHtml, stripHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
23
23
  deepClone, deepMerge, isEqual, param, parseQuery,
24
- storage, session, bus,
24
+ storage, session, EventBus, bus,
25
+ range, unique, chunk, groupBy,
26
+ pick, omit, getPath, setPath, isEmpty,
27
+ capitalize, truncate, clamp,
28
+ memoize, retry, timeout,
25
29
  } from './src/utils.js';
26
30
  import { ZQueryError, ErrorCode, onError, reportError, guardCallback, validate } from './src/errors.js';
27
31
 
@@ -61,6 +65,8 @@ Object.defineProperty($, 'name', {
61
65
  value: query.name, writable: true, configurable: true
62
66
  });
63
67
  $.children = query.children;
68
+ $.qs = query.qs;
69
+ $.qsa = query.qsa;
64
70
 
65
71
  // --- Collection selector ---------------------------------------------------
66
72
  /**
@@ -129,8 +135,10 @@ $.pipe = pipe;
129
135
  $.once = once;
130
136
  $.sleep = sleep;
131
137
  $.escapeHtml = escapeHtml;
138
+ $.stripHtml = stripHtml;
132
139
  $.html = html;
133
140
  $.trust = trust;
141
+ $.TrustedHTML = TrustedHTML;
134
142
  $.uuid = uuid;
135
143
  $.camelCase = camelCase;
136
144
  $.kebabCase = kebabCase;
@@ -141,7 +149,23 @@ $.param = param;
141
149
  $.parseQuery = parseQuery;
142
150
  $.storage = storage;
143
151
  $.session = session;
152
+ $.EventBus = EventBus;
144
153
  $.bus = bus;
154
+ $.range = range;
155
+ $.unique = unique;
156
+ $.chunk = chunk;
157
+ $.groupBy = groupBy;
158
+ $.pick = pick;
159
+ $.omit = omit;
160
+ $.getPath = getPath;
161
+ $.setPath = setPath;
162
+ $.isEmpty = isEmpty;
163
+ $.capitalize = capitalize;
164
+ $.truncate = truncate;
165
+ $.clamp = clamp;
166
+ $.memoize = memoize;
167
+ $.retry = retry;
168
+ $.timeout = timeout;
145
169
 
146
170
  // --- Error handling --------------------------------------------------------
147
171
  $.onError = onError;
@@ -189,9 +213,13 @@ export {
189
213
  http,
190
214
  ZQueryError, ErrorCode, onError, reportError, guardCallback, validate,
191
215
  debounce, throttle, pipe, once, sleep,
192
- escapeHtml, html, trust, uuid, camelCase, kebabCase,
216
+ escapeHtml, stripHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
193
217
  deepClone, deepMerge, isEqual, param, parseQuery,
194
- storage, session, bus,
218
+ storage, session, EventBus, bus,
219
+ range, unique, chunk, groupBy,
220
+ pick, omit, getPath, setPath, isEmpty,
221
+ capitalize, truncate, clamp,
222
+ memoize, retry, timeout,
195
223
  };
196
224
 
197
225
  export default $;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-query",
3
- "version": "0.9.0",
3
+ "version": "0.9.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",
package/src/component.js CHANGED
@@ -264,7 +264,7 @@ class Component {
264
264
  if (!watchers) return;
265
265
  for (const [key, handler] of Object.entries(watchers)) {
266
266
  // Match exact key or parent key (e.g. watcher on 'user' fires when 'user.name' changes)
267
- if (changedKey === key || key.startsWith(changedKey + '.') || changedKey.startsWith(key + '.') || changedKey === key) {
267
+ if (changedKey === key || key.startsWith(changedKey + '.') || changedKey.startsWith(key + '.')) {
268
268
  const currentVal = _getPath(this.state.__raw || this.state, key);
269
269
  const prevVal = this._prevWatchValues?.[key];
270
270
  if (currentVal !== prevVal) {
@@ -413,20 +413,26 @@ class Component {
413
413
  if (!this._mounted && combinedStyles) {
414
414
  const scopeAttr = `z-s${this._uid}`;
415
415
  this._el.setAttribute(scopeAttr, '');
416
- let inAtBlock = 0;
416
+ let noScopeDepth = 0; // brace depth at which a no-scope @-rule started (0 = none active)
417
+ let braceDepth = 0; // overall brace depth
417
418
  const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
418
419
  if (match === '}') {
419
- if (inAtBlock > 0) inAtBlock--;
420
+ if (noScopeDepth > 0 && braceDepth <= noScopeDepth) noScopeDepth = 0;
421
+ braceDepth--;
420
422
  return match;
421
423
  }
424
+ braceDepth++;
422
425
  const trimmed = selector.trim();
423
- // Don't scope @-rules (@media, @keyframes, @supports, @container, @layer, @font-face, etc.)
426
+ // Don't scope @-rules themselves
424
427
  if (trimmed.startsWith('@')) {
425
- inAtBlock++;
428
+ // @keyframes and @font-face contain non-selector content — skip scoping inside them
429
+ if (/^@(keyframes|font-face)\b/.test(trimmed)) {
430
+ noScopeDepth = braceDepth;
431
+ }
426
432
  return match;
427
433
  }
428
- // Don't scope keyframe stops (from, to, 0%, 50%, etc.)
429
- if (inAtBlock > 0 && /^[\d%\s,fromto]+$/.test(trimmed.replace(/\s/g, ''))) {
434
+ // Inside @keyframes or @font-face don't scope inner rules
435
+ if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
430
436
  return match;
431
437
  }
432
438
  return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
@@ -614,15 +620,44 @@ class Component {
614
620
  const handler = (e) => {
615
621
  // Read bindings from live map — always up to date after re-renders
616
622
  const currentBindings = this._eventBindings?.get(event) || [];
617
- for (const { selector, methodExpr, modifiers, el } of currentBindings) {
618
- if (!e.target.closest(selector)) continue;
623
+
624
+ // Collect matching bindings with their matched elements, then sort
625
+ // deepest-first so .stop correctly prevents ancestor handlers
626
+ // (mimics real DOM bubbling order within delegated events).
627
+ const hits = [];
628
+ for (const binding of currentBindings) {
629
+ const matched = e.target.closest(binding.selector);
630
+ if (!matched) continue;
631
+ hits.push({ ...binding, matched });
632
+ }
633
+ hits.sort((a, b) => {
634
+ if (a.matched === b.matched) return 0;
635
+ return a.matched.contains(b.matched) ? 1 : -1;
636
+ });
637
+
638
+ let stoppedAt = null; // Track elements that called .stop
639
+ for (const { selector, methodExpr, modifiers, el, matched } of hits) {
640
+
641
+ // In delegated events, .stop should prevent ancestor bindings from
642
+ // firing — stopPropagation alone only stops real DOM bubbling.
643
+ if (stoppedAt) {
644
+ let blocked = false;
645
+ for (const stopped of stoppedAt) {
646
+ if (matched.contains(stopped) && matched !== stopped) { blocked = true; break; }
647
+ }
648
+ if (blocked) continue;
649
+ }
619
650
 
620
651
  // .self — only fire if target is the element itself
621
652
  if (modifiers.includes('self') && e.target !== el) continue;
622
653
 
623
654
  // Handle modifiers
624
655
  if (modifiers.includes('prevent')) e.preventDefault();
625
- if (modifiers.includes('stop')) e.stopPropagation();
656
+ if (modifiers.includes('stop')) {
657
+ e.stopPropagation();
658
+ if (!stoppedAt) stoppedAt = [];
659
+ stoppedAt.push(matched);
660
+ }
626
661
 
627
662
  // Build the invocation function
628
663
  const invoke = (evt) => {
@@ -1054,6 +1089,21 @@ class Component {
1054
1089
  this._listeners = [];
1055
1090
  this._delegatedEvents = null;
1056
1091
  this._eventBindings = null;
1092
+ // Clear any pending debounce/throttle timers to prevent stale closures.
1093
+ // Timers are keyed by individual child elements, so iterate all descendants.
1094
+ const allEls = this._el.querySelectorAll('*');
1095
+ allEls.forEach(child => {
1096
+ const dTimers = _debounceTimers.get(child);
1097
+ if (dTimers) {
1098
+ for (const key in dTimers) clearTimeout(dTimers[key]);
1099
+ _debounceTimers.delete(child);
1100
+ }
1101
+ const tTimers = _throttleTimers.get(child);
1102
+ if (tTimers) {
1103
+ for (const key in tTimers) clearTimeout(tTimers[key]);
1104
+ _throttleTimers.delete(child);
1105
+ }
1106
+ });
1057
1107
  if (this._styleEl) this._styleEl.remove();
1058
1108
  _instances.delete(this._el);
1059
1109
  this._el.innerHTML = '';
package/src/core.js CHANGED
@@ -12,7 +12,7 @@ import { morph as _morph, morphElement as _morphElement } from './diff.js';
12
12
  // ---------------------------------------------------------------------------
13
13
  export class ZQueryCollection {
14
14
  constructor(elements) {
15
- this.elements = Array.isArray(elements) ? elements : [elements];
15
+ this.elements = Array.isArray(elements) ? elements : (elements ? [elements] : []);
16
16
  this.length = this.elements.length;
17
17
  this.elements.forEach((el, i) => { this[i] = el; });
18
18
  }
@@ -69,10 +69,12 @@ export class ZQueryCollection {
69
69
  return new ZQueryCollection([...kids]);
70
70
  }
71
71
 
72
- siblings() {
72
+ siblings(selector) {
73
73
  const sibs = [];
74
74
  this.elements.forEach(el => {
75
- sibs.push(...[...el.parentElement.children].filter(c => c !== el));
75
+ if (!el.parentElement) return;
76
+ const all = [...el.parentElement.children].filter(c => c !== el);
77
+ sibs.push(...(selector ? all.filter(c => c.matches(selector)) : all));
76
78
  });
77
79
  return new ZQueryCollection(sibs);
78
80
  }
@@ -214,7 +216,8 @@ export class ZQueryCollection {
214
216
  index(selector) {
215
217
  if (selector === undefined) {
216
218
  const el = this.first();
217
- return el ? Array.from(el.parentElement.children).indexOf(el) : -1;
219
+ if (!el || !el.parentElement) return -1;
220
+ return Array.from(el.parentElement.children).indexOf(el);
218
221
  }
219
222
  const target = (typeof selector === 'string')
220
223
  ? document.querySelector(selector)
@@ -274,6 +277,11 @@ export class ZQueryCollection {
274
277
  // --- Attributes ----------------------------------------------------------
275
278
 
276
279
  attr(name, value) {
280
+ if (typeof name === 'object' && name !== null) {
281
+ return this.each((_, el) => {
282
+ for (const [k, v] of Object.entries(name)) el.setAttribute(k, v);
283
+ });
284
+ }
277
285
  if (value === undefined) return this.first()?.getAttribute(name);
278
286
  return this.each((_, el) => el.setAttribute(name, value));
279
287
  }
@@ -298,7 +306,10 @@ export class ZQueryCollection {
298
306
 
299
307
  // --- CSS / Dimensions ----------------------------------------------------
300
308
 
301
- css(props) {
309
+ css(props, value) {
310
+ if (typeof props === 'string' && value !== undefined) {
311
+ return this.each((_, el) => { el.style[props] = value; });
312
+ }
302
313
  if (typeof props === 'string') {
303
314
  const el = this.first();
304
315
  return el ? getComputedStyle(el)[props] : undefined;
@@ -438,6 +449,7 @@ export class ZQueryCollection {
438
449
  wrap(wrapper) {
439
450
  return this.each((_, el) => {
440
451
  const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
452
+ if (!w || !el.parentNode) return;
441
453
  el.parentNode.insertBefore(w, el);
442
454
  w.appendChild(el);
443
455
  });
@@ -563,12 +575,18 @@ export class ZQueryCollection {
563
575
  if (typeof selectorOrHandler === 'function') {
564
576
  el.addEventListener(evt, selectorOrHandler);
565
577
  } else if (typeof selectorOrHandler === 'string') {
566
- // Delegated event — only works on elements that support closest()
567
- el.addEventListener(evt, (e) => {
578
+ // Delegated event — store wrapper so off() can remove it
579
+ const wrapper = (e) => {
568
580
  if (!e.target || typeof e.target.closest !== 'function') return;
569
581
  const target = e.target.closest(selectorOrHandler);
570
582
  if (target && el.contains(target)) handler.call(target, e);
571
- });
583
+ };
584
+ wrapper._zqOriginal = handler;
585
+ wrapper._zqSelector = selectorOrHandler;
586
+ el.addEventListener(evt, wrapper);
587
+ // Track delegated handlers for removal
588
+ if (!el._zqDelegated) el._zqDelegated = [];
589
+ el._zqDelegated.push({ evt, wrapper });
572
590
  }
573
591
  });
574
592
  });
@@ -577,7 +595,20 @@ export class ZQueryCollection {
577
595
  off(event, handler) {
578
596
  const events = event.split(/\s+/);
579
597
  return this.each((_, el) => {
580
- events.forEach(evt => el.removeEventListener(evt, handler));
598
+ events.forEach(evt => {
599
+ // Try direct removal first
600
+ el.removeEventListener(evt, handler);
601
+ // Also check delegated handlers
602
+ if (el._zqDelegated) {
603
+ el._zqDelegated = el._zqDelegated.filter(d => {
604
+ if (d.evt === evt && d.wrapper._zqOriginal === handler) {
605
+ el.removeEventListener(evt, d.wrapper);
606
+ return false;
607
+ }
608
+ return true;
609
+ });
610
+ }
611
+ });
581
612
  });
582
613
  }
583
614
 
@@ -606,8 +637,12 @@ export class ZQueryCollection {
606
637
  // --- Animation -----------------------------------------------------------
607
638
 
608
639
  animate(props, duration = 300, easing = 'ease') {
640
+ // Empty collection — resolve immediately
641
+ if (this.length === 0) return Promise.resolve(this);
609
642
  return new Promise(resolve => {
643
+ let resolved = false;
610
644
  const count = { done: 0 };
645
+ const listeners = [];
611
646
  this.each((_, el) => {
612
647
  el.style.transition = `all ${duration}ms ${easing}`;
613
648
  requestAnimationFrame(() => {
@@ -615,13 +650,27 @@ export class ZQueryCollection {
615
650
  const onEnd = () => {
616
651
  el.removeEventListener('transitionend', onEnd);
617
652
  el.style.transition = '';
618
- if (++count.done >= this.length) resolve(this);
653
+ if (!resolved && ++count.done >= this.length) {
654
+ resolved = true;
655
+ resolve(this);
656
+ }
619
657
  };
620
658
  el.addEventListener('transitionend', onEnd);
659
+ listeners.push({ el, onEnd });
621
660
  });
622
661
  });
623
662
  // Fallback in case transitionend doesn't fire
624
- setTimeout(() => resolve(this), duration + 50);
663
+ setTimeout(() => {
664
+ if (!resolved) {
665
+ resolved = true;
666
+ // Clean up any remaining transitionend listeners
667
+ for (const { el, onEnd } of listeners) {
668
+ el.removeEventListener('transitionend', onEnd);
669
+ el.style.transition = '';
670
+ }
671
+ resolve(this);
672
+ }
673
+ }, duration + 50);
625
674
  });
626
675
  }
627
676
 
@@ -635,7 +684,8 @@ export class ZQueryCollection {
635
684
 
636
685
  fadeToggle(duration = 300) {
637
686
  return Promise.all(this.elements.map(el => {
638
- const visible = getComputedStyle(el).opacity !== '0' && getComputedStyle(el).display !== 'none';
687
+ const cs = getComputedStyle(el);
688
+ const visible = cs.opacity !== '0' && cs.display !== 'none';
639
689
  const col = new ZQueryCollection([el]);
640
690
  return visible ? col.fadeOut(duration) : col.fadeIn(duration);
641
691
  })).then(() => this);
@@ -813,6 +863,8 @@ query.children = (parentId) => {
813
863
  const p = document.getElementById(parentId);
814
864
  return new ZQueryCollection(p ? Array.from(p.children) : []);
815
865
  };
866
+ query.qs = (sel, ctx = document) => ctx.querySelector(sel);
867
+ query.qsa = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
816
868
 
817
869
  // Create element shorthand — returns ZQueryCollection for chaining
818
870
  query.create = (tag, attrs = {}, ...children) => {
@@ -820,7 +872,7 @@ query.create = (tag, attrs = {}, ...children) => {
820
872
  for (const [k, v] of Object.entries(attrs)) {
821
873
  if (k === 'class') el.className = v;
822
874
  else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
823
- else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
875
+ else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
824
876
  else if (k === 'data' && typeof v === 'object') Object.entries(v).forEach(([dk, dv]) => { el.dataset[dk] = dv; });
825
877
  else el.setAttribute(k, v);
826
878
  }
package/src/diff.js CHANGED
@@ -239,8 +239,11 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
239
239
  if (!lisSet.has(i)) {
240
240
  oldParent.insertBefore(oldNode, cursor);
241
241
  }
242
+ // Capture next sibling BEFORE _morphNode — if _morphNode calls
243
+ // replaceChild, oldNode is removed and nextSibling becomes stale.
244
+ const nextSib = oldNode.nextSibling;
242
245
  _morphNode(oldParent, oldNode, newNode);
243
- cursor = oldNode.nextSibling;
246
+ cursor = nextSib;
244
247
  } else {
245
248
  // Insert new node
246
249
  const clone = newNode.cloneNode(true);
@@ -418,10 +421,13 @@ function _morphAttributes(oldEl, newEl) {
418
421
  }
419
422
  }
420
423
 
421
- // Remove stale attributes
422
- for (let i = oldLen - 1; i >= 0; i--) {
423
- if (!newNames.has(oldAttrs[i].name)) {
424
- oldEl.removeAttribute(oldAttrs[i].name);
424
+ // Remove stale attributes — snapshot names first because oldAttrs
425
+ // is a live NamedNodeMap that mutates on removeAttribute().
426
+ const oldNames = new Array(oldLen);
427
+ for (let i = 0; i < oldLen; i++) oldNames[i] = oldAttrs[i].name;
428
+ for (let i = oldNames.length - 1; i >= 0; i--) {
429
+ if (!newNames.has(oldNames[i])) {
430
+ oldEl.removeAttribute(oldNames[i]);
425
431
  }
426
432
  }
427
433
  }