zero-query 0.2.9 → 0.4.9

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/dist/zquery.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * zQuery (zeroQuery) v0.2.9
2
+ * zQuery (zeroQuery) v0.4.9
3
3
  * Lightweight Frontend Library
4
4
  * https://github.com/tonywied17/zero-query
5
5
  * (c) 2026 Anthony Wiedman — MIT License
@@ -633,14 +633,27 @@ query.ready = (fn) => {
633
633
  else document.addEventListener('DOMContentLoaded', fn);
634
634
  };
635
635
 
636
- // Global event delegation
637
- query.on = (event, selector, handler) => {
636
+ // Global event listeners — supports direct and delegated forms
637
+ // $.on('keydown', handler) direct listener on document
638
+ // $.on('click', '.btn', handler) → delegated via closest()
639
+ query.on = (event, selectorOrHandler, handler) => {
640
+ if (typeof selectorOrHandler === 'function') {
641
+ // 2-arg: direct document listener (keydown, resize, etc.)
642
+ document.addEventListener(event, selectorOrHandler);
643
+ return;
644
+ }
645
+ // 3-arg: delegated
638
646
  document.addEventListener(event, (e) => {
639
- const target = e.target.closest(selector);
647
+ const target = e.target.closest(selectorOrHandler);
640
648
  if (target) handler.call(target, e);
641
649
  });
642
650
  };
643
651
 
652
+ // Remove a direct global listener
653
+ query.off = (event, handler) => {
654
+ document.removeEventListener(event, handler);
655
+ };
656
+
644
657
  // Extend collection prototype (like $.fn in jQuery)
645
658
  query.fn = ZQueryCollection.prototype;
646
659
 
@@ -648,7 +661,7 @@ query.fn = ZQueryCollection.prototype;
648
661
  /**
649
662
  * zQuery Component — Lightweight reactive component system
650
663
  *
651
- * Declarative components using template literals (no JSX, no build step).
664
+ * Declarative components using template literals with directive support.
652
665
  * Proxy-based state triggers targeted re-renders via event delegation.
653
666
  *
654
667
  * Features:
@@ -677,6 +690,18 @@ const _resourceCache = new Map(); // url → Promise<string>
677
690
  // Unique ID counter
678
691
  let _uid = 0;
679
692
 
693
+ // Inject z-cloak base style and mobile tap-highlight reset (once, globally)
694
+ if (typeof document !== 'undefined' && !document.querySelector('[data-zq-cloak]')) {
695
+ const _s = document.createElement('style');
696
+ _s.textContent = '[z-cloak]{display:none!important}*,*::before,*::after{-webkit-tap-highlight-color:transparent}';
697
+ _s.setAttribute('data-zq-cloak', '');
698
+ document.head.appendChild(_s);
699
+ }
700
+
701
+ // Debounce / throttle helpers for event modifiers
702
+ const _debounceTimers = new WeakMap();
703
+ const _throttleTimers = new WeakMap();
704
+
680
705
  /**
681
706
  * Fetch and cache a text resource (HTML template or CSS file).
682
707
  * @param {string} url — URL to fetch
@@ -861,8 +886,11 @@ class Component {
861
886
  if (this._updateQueued) return;
862
887
  this._updateQueued = true;
863
888
  queueMicrotask(() => {
864
- this._updateQueued = false;
865
- if (!this._destroyed) this._render();
889
+ try {
890
+ if (!this._destroyed) this._render();
891
+ } finally {
892
+ this._updateQueued = false;
893
+ }
866
894
  });
867
895
  }
868
896
 
@@ -890,12 +918,14 @@ class Component {
890
918
  // items: ['page-a', { id: 'page-b', label: 'Page B' }, ...]
891
919
  // }
892
920
  // Exposes this.pages (array of {id,label}), this.activePage (current id)
921
+ // Pages are lazy-loaded: only the active page is fetched on first render,
922
+ // remaining pages are prefetched in the background for instant navigation.
893
923
  //
894
924
  async _loadExternals() {
895
925
  const def = this._def;
896
926
  const base = def._base; // auto-detected or explicit
897
927
 
898
- // ── Pages config ─────────────────────────────────────────────
928
+ // -- Pages config ---------------------------------------------
899
929
  if (def.pages && !def._pagesNormalized) {
900
930
  const p = def.pages;
901
931
  const ext = p.ext || '.html';
@@ -907,18 +937,20 @@ class Component {
907
937
  return { id: item.id, label: item.label || _titleCase(item.id) };
908
938
  });
909
939
 
910
- // Auto-generate templateUrl object map
911
- if (!def.templateUrl) {
912
- def.templateUrl = {};
913
- for (const { id } of def._pages) {
914
- def.templateUrl[id] = `${dir}/${id}${ext}`;
915
- }
940
+ // Build URL map for lazy per-page loading.
941
+ // Pages are fetched on demand (active page first, rest prefetched in
942
+ // the background) so the component renders as soon as the visible
943
+ // page is ready instead of waiting for every page to download.
944
+ def._pageUrls = {};
945
+ for (const { id } of def._pages) {
946
+ def._pageUrls[id] = `${dir}/${id}${ext}`;
916
947
  }
948
+ if (!def._externalTemplates) def._externalTemplates = {};
917
949
 
918
950
  def._pagesNormalized = true;
919
951
  }
920
952
 
921
- // ── External templates ──────────────────────────────────────
953
+ // -- External templates --------------------------------------
922
954
  if (def.templateUrl && !def._templateLoaded) {
923
955
  const tu = def.templateUrl;
924
956
  if (typeof tu === 'string') {
@@ -940,7 +972,7 @@ class Component {
940
972
  def._templateLoaded = true;
941
973
  }
942
974
 
943
- // ── External styles ─────────────────────────────────────────
975
+ // -- External styles -----------------------------------------
944
976
  if (def.styleUrl && !def._styleLoaded) {
945
977
  const su = def.styleUrl;
946
978
  if (typeof su === 'string') {
@@ -975,7 +1007,37 @@ class Component {
975
1007
  if (this._def._pages) {
976
1008
  this.pages = this._def._pages;
977
1009
  const pc = this._def.pages;
978
- this.activePage = (pc.param && this.props.$params?.[pc.param]) || pc.default || this._def._pages[0]?.id || '';
1010
+ let active = (pc.param && this.props.$params?.[pc.param]) || pc.default || this._def._pages[0]?.id || '';
1011
+
1012
+ // Fall back to default if the param doesn't match any known page
1013
+ if (this._def._pageUrls && !(active in this._def._pageUrls)) {
1014
+ active = pc.default || this._def._pages[0]?.id || '';
1015
+ }
1016
+ this.activePage = active;
1017
+
1018
+ // Lazy-load: fetch only the active page's template on demand
1019
+ if (this._def._pageUrls && !(active in this._def._externalTemplates)) {
1020
+ const url = this._def._pageUrls[active];
1021
+ if (url) {
1022
+ _fetchResource(url).then(html => {
1023
+ this._def._externalTemplates[active] = html;
1024
+ if (!this._destroyed) this._render();
1025
+ });
1026
+ return; // Wait for active page before rendering
1027
+ }
1028
+ }
1029
+
1030
+ // Prefetch remaining pages in background (once, after active page is ready)
1031
+ if (this._def._pageUrls && !this._def._pagesPrefetched) {
1032
+ this._def._pagesPrefetched = true;
1033
+ for (const [id, url] of Object.entries(this._def._pageUrls)) {
1034
+ if (!(id in this._def._externalTemplates)) {
1035
+ _fetchResource(url).then(html => {
1036
+ this._def._externalTemplates[id] = html;
1037
+ });
1038
+ }
1039
+ }
1040
+ }
979
1041
  }
980
1042
 
981
1043
  // Determine HTML content
@@ -983,9 +1045,13 @@ class Component {
983
1045
  if (this._def.render) {
984
1046
  // Inline render function takes priority
985
1047
  html = this._def.render.call(this);
1048
+ // Expand z-for in render templates ({{}} expressions for iteration items)
1049
+ html = this._expandZFor(html);
986
1050
  } else if (this._def._externalTemplate) {
987
- // External template with {{expression}} interpolation
988
- html = this._def._externalTemplate.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
1051
+ // Expand z-for FIRST (before global {{}} interpolation)
1052
+ html = this._expandZFor(this._def._externalTemplate);
1053
+ // Then do global {{expression}} interpolation on the remaining content
1054
+ html = html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
989
1055
  try {
990
1056
  return new Function('state', 'props', '$', `with(state){return ${expr.trim()}}`)(
991
1057
  this.state.__raw || this.state,
@@ -1018,16 +1084,35 @@ class Component {
1018
1084
  this._styleEl = styleEl;
1019
1085
  }
1020
1086
 
1021
- // ── Focus preservation for z-model ────────────────────────────
1087
+ // -- Focus preservation ----------------------------------------
1022
1088
  // Before replacing innerHTML, save focus state so we can restore
1023
- // cursor position after the DOM is rebuilt.
1089
+ // cursor position after the DOM is rebuilt. Works for any focused
1090
+ // input/textarea/select inside the component, not only z-model.
1024
1091
  let _focusInfo = null;
1025
1092
  const _active = document.activeElement;
1026
1093
  if (_active && this._el.contains(_active)) {
1027
1094
  const modelKey = _active.getAttribute?.('z-model');
1095
+ const refKey = _active.getAttribute?.('z-ref');
1096
+ // Build a selector that can locate the same element after re-render
1097
+ let selector = null;
1028
1098
  if (modelKey) {
1099
+ selector = `[z-model="${modelKey}"]`;
1100
+ } else if (refKey) {
1101
+ selector = `[z-ref="${refKey}"]`;
1102
+ } else {
1103
+ // Fallback: match by tag + type + name + placeholder combination
1104
+ const tag = _active.tagName.toLowerCase();
1105
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
1106
+ let s = tag;
1107
+ if (_active.type) s += `[type="${_active.type}"]`;
1108
+ if (_active.name) s += `[name="${_active.name}"]`;
1109
+ if (_active.placeholder) s += `[placeholder="${CSS.escape(_active.placeholder)}"]`;
1110
+ selector = s;
1111
+ }
1112
+ }
1113
+ if (selector) {
1029
1114
  _focusInfo = {
1030
- key: modelKey,
1115
+ selector,
1031
1116
  start: _active.selectionStart,
1032
1117
  end: _active.selectionEnd,
1033
1118
  dir: _active.selectionDirection,
@@ -1038,14 +1123,17 @@ class Component {
1038
1123
  // Update DOM
1039
1124
  this._el.innerHTML = html;
1040
1125
 
1041
- // Process directives
1126
+ // Process structural & attribute directives
1127
+ this._processDirectives();
1128
+
1129
+ // Process event, ref, and model bindings
1042
1130
  this._bindEvents();
1043
1131
  this._bindRefs();
1044
1132
  this._bindModels();
1045
1133
 
1046
- // Restore focus to z-model element after re-render
1134
+ // Restore focus after re-render
1047
1135
  if (_focusInfo) {
1048
- const el = this._el.querySelector(`[z-model="${_focusInfo.key}"]`);
1136
+ const el = this._el.querySelector(_focusInfo.selector);
1049
1137
  if (el) {
1050
1138
  el.focus();
1051
1139
  try {
@@ -1067,7 +1155,7 @@ class Component {
1067
1155
  }
1068
1156
  }
1069
1157
 
1070
- // Bind @event="method" handlers via delegation
1158
+ // Bind @event="method" and z-on:event="method" handlers via delegation
1071
1159
  _bindEvents() {
1072
1160
  // Clean up old delegated listeners
1073
1161
  this._listeners.forEach(({ event, handler }) => {
@@ -1075,15 +1163,25 @@ class Component {
1075
1163
  });
1076
1164
  this._listeners = [];
1077
1165
 
1078
- // Find all elements with @event attributes
1166
+ // Find all elements with @event or z-on:event attributes
1079
1167
  const allEls = this._el.querySelectorAll('*');
1080
1168
  const eventMap = new Map(); // event → [{ selector, method, modifiers }]
1081
1169
 
1082
1170
  allEls.forEach(child => {
1171
+ // Skip elements inside z-pre subtrees
1172
+ if (child.closest('[z-pre]')) return;
1173
+
1083
1174
  [...child.attributes].forEach(attr => {
1084
- if (!attr.name.startsWith('@')) return;
1175
+ // Support both @event and z-on:event syntax
1176
+ let raw;
1177
+ if (attr.name.startsWith('@')) {
1178
+ raw = attr.name.slice(1); // @click.prevent → click.prevent
1179
+ } else if (attr.name.startsWith('z-on:')) {
1180
+ raw = attr.name.slice(5); // z-on:click.prevent → click.prevent
1181
+ } else {
1182
+ return;
1183
+ }
1085
1184
 
1086
- const raw = attr.name.slice(1); // e.g. "click.prevent"
1087
1185
  const parts = raw.split('.');
1088
1186
  const event = parts[0];
1089
1187
  const modifiers = parts.slice(1);
@@ -1102,43 +1200,83 @@ class Component {
1102
1200
 
1103
1201
  // Register delegated listeners on the component root
1104
1202
  for (const [event, bindings] of eventMap) {
1203
+ // Determine listener options from modifiers
1204
+ const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
1205
+ const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
1206
+ const listenerOpts = (needsCapture || needsPassive)
1207
+ ? { capture: needsCapture, passive: needsPassive }
1208
+ : false;
1209
+
1105
1210
  const handler = (e) => {
1106
1211
  for (const { selector, methodExpr, modifiers, el } of bindings) {
1107
1212
  if (!e.target.closest(selector)) continue;
1108
1213
 
1214
+ // .self — only fire if target is the element itself
1215
+ if (modifiers.includes('self') && e.target !== el) continue;
1216
+
1109
1217
  // Handle modifiers
1110
1218
  if (modifiers.includes('prevent')) e.preventDefault();
1111
1219
  if (modifiers.includes('stop')) e.stopPropagation();
1112
1220
 
1113
- // Parse method expression: "method" or "method(arg1, arg2)"
1114
- const match = methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
1115
- if (match) {
1221
+ // Build the invocation function
1222
+ const invoke = (evt) => {
1223
+ const match = methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
1224
+ if (!match) return;
1116
1225
  const methodName = match[1];
1117
1226
  const fn = this[methodName];
1118
- if (typeof fn === 'function') {
1119
- if (match[2] !== undefined) {
1120
- // Parse arguments (supports strings, numbers, state refs)
1121
- const args = match[2].split(',').map(a => {
1122
- a = a.trim();
1123
- if (a === '') return undefined;
1124
- if (a === 'true') return true;
1125
- if (a === 'false') return false;
1126
- if (a === 'null') return null;
1127
- if (/^-?\d+(\.\d+)?$/.test(a)) return Number(a);
1128
- if ((a.startsWith("'") && a.endsWith("'")) || (a.startsWith('"') && a.endsWith('"'))) return a.slice(1, -1);
1129
- // State reference
1130
- if (a.startsWith('state.')) return this.state[a.slice(6)];
1131
- return a;
1132
- }).filter(a => a !== undefined);
1133
- fn(e, ...args);
1134
- } else {
1135
- fn(e);
1136
- }
1227
+ if (typeof fn !== 'function') return;
1228
+ if (match[2] !== undefined) {
1229
+ const args = match[2].split(',').map(a => {
1230
+ a = a.trim();
1231
+ if (a === '') return undefined;
1232
+ if (a === '$event') return evt;
1233
+ if (a === 'true') return true;
1234
+ if (a === 'false') return false;
1235
+ if (a === 'null') return null;
1236
+ if (/^-?\d+(\.\d+)?$/.test(a)) return Number(a);
1237
+ if ((a.startsWith("'") && a.endsWith("'")) || (a.startsWith('"') && a.endsWith('"'))) return a.slice(1, -1);
1238
+ if (a.startsWith('state.')) return _getPath(this.state, a.slice(6));
1239
+ return a;
1240
+ }).filter(a => a !== undefined);
1241
+ fn(...args);
1242
+ } else {
1243
+ fn(evt);
1137
1244
  }
1245
+ };
1246
+
1247
+ // .debounce.{ms} — delay invocation until idle
1248
+ const debounceIdx = modifiers.indexOf('debounce');
1249
+ if (debounceIdx !== -1) {
1250
+ const ms = parseInt(modifiers[debounceIdx + 1], 10) || 250;
1251
+ const timers = _debounceTimers.get(el) || {};
1252
+ clearTimeout(timers[event]);
1253
+ timers[event] = setTimeout(() => invoke(e), ms);
1254
+ _debounceTimers.set(el, timers);
1255
+ continue;
1138
1256
  }
1257
+
1258
+ // .throttle.{ms} — fire at most once per interval
1259
+ const throttleIdx = modifiers.indexOf('throttle');
1260
+ if (throttleIdx !== -1) {
1261
+ const ms = parseInt(modifiers[throttleIdx + 1], 10) || 250;
1262
+ const timers = _throttleTimers.get(el) || {};
1263
+ if (timers[event]) continue;
1264
+ invoke(e);
1265
+ timers[event] = setTimeout(() => { timers[event] = null; }, ms);
1266
+ _throttleTimers.set(el, timers);
1267
+ continue;
1268
+ }
1269
+
1270
+ // .once — fire once then ignore
1271
+ if (modifiers.includes('once')) {
1272
+ if (el.dataset.zqOnce === event) continue;
1273
+ el.dataset.zqOnce = event;
1274
+ }
1275
+
1276
+ invoke(e);
1139
1277
  }
1140
1278
  };
1141
- this._el.addEventListener(event, handler);
1279
+ this._el.addEventListener(event, handler, listenerOpts);
1142
1280
  this._listeners.push({ event, handler });
1143
1281
  }
1144
1282
  }
@@ -1179,7 +1317,7 @@ class Component {
1179
1317
  // Read current state value (supports dot-path keys)
1180
1318
  const currentVal = _getPath(this.state, key);
1181
1319
 
1182
- // ── Set initial DOM value from state ────────────────────────
1320
+ // -- Set initial DOM value from state ------------------------
1183
1321
  if (tag === 'input' && type === 'checkbox') {
1184
1322
  el.checked = !!currentVal;
1185
1323
  } else if (tag === 'input' && type === 'radio') {
@@ -1195,12 +1333,12 @@ class Component {
1195
1333
  el.value = currentVal ?? '';
1196
1334
  }
1197
1335
 
1198
- // ── Determine event type ────────────────────────────────────
1336
+ // -- Determine event type ------------------------------------
1199
1337
  const event = isLazy || tag === 'select' || type === 'checkbox' || type === 'radio'
1200
1338
  ? 'change'
1201
1339
  : isEditable ? 'input' : 'input';
1202
1340
 
1203
- // ── Handler: read DOM → write to reactive state ─────────────
1341
+ // -- Handler: read DOM → write to reactive state -------------
1204
1342
  const handler = () => {
1205
1343
  let val;
1206
1344
  if (type === 'checkbox') val = el.checked;
@@ -1221,9 +1359,240 @@ class Component {
1221
1359
  });
1222
1360
  }
1223
1361
 
1362
+ // ---------------------------------------------------------------------------
1363
+ // Expression evaluator — runs expr in component context (state, props, refs)
1364
+ // ---------------------------------------------------------------------------
1365
+ _evalExpr(expr) {
1366
+ try {
1367
+ return new Function('state', 'props', 'refs', '$',
1368
+ `with(state){return (${expr})}`)(
1369
+ this.state.__raw || this.state,
1370
+ this.props,
1371
+ this.refs,
1372
+ typeof window !== 'undefined' ? window.$ : undefined
1373
+ );
1374
+ } catch { return undefined; }
1375
+ }
1376
+
1377
+ // ---------------------------------------------------------------------------
1378
+ // z-for — Expand list-rendering directives (pre-innerHTML, string level)
1379
+ //
1380
+ // <li z-for="item in items">{{item.name}}</li>
1381
+ // <li z-for="(item, i) in items">{{i}}: {{item.name}}</li>
1382
+ // <div z-for="n in 5">{{n}}</div> (range)
1383
+ // <div z-for="(val, key) in obj">{{key}}: {{val}}</div> (object)
1384
+ //
1385
+ // Uses a temporary DOM to parse, clone elements per item, and evaluate
1386
+ // {{}} expressions with the iteration variable in scope.
1387
+ // ---------------------------------------------------------------------------
1388
+ _expandZFor(html) {
1389
+ if (!html.includes('z-for')) return html;
1390
+
1391
+ const temp = document.createElement('div');
1392
+ temp.innerHTML = html;
1393
+
1394
+ const _recurse = (root) => {
1395
+ // Process innermost z-for elements first (no nested z-for inside)
1396
+ let forEls = [...root.querySelectorAll('[z-for]')]
1397
+ .filter(el => !el.querySelector('[z-for]'));
1398
+ if (!forEls.length) return;
1399
+
1400
+ for (const el of forEls) {
1401
+ if (!el.parentNode) continue; // already removed
1402
+ const expr = el.getAttribute('z-for');
1403
+ const m = expr.match(
1404
+ /^\s*(?:\(\s*(\w+)(?:\s*,\s*(\w+))?\s*\)|(\w+))\s+in\s+(.+)\s*$/
1405
+ );
1406
+ if (!m) { el.removeAttribute('z-for'); continue; }
1407
+
1408
+ const itemVar = m[1] || m[3];
1409
+ const indexVar = m[2] || '$index';
1410
+ const listExpr = m[4].trim();
1411
+
1412
+ let list = this._evalExpr(listExpr);
1413
+ if (list == null) { el.remove(); continue; }
1414
+ // Number range: z-for="n in 5" → [1, 2, 3, 4, 5]
1415
+ if (typeof list === 'number') {
1416
+ list = Array.from({ length: list }, (_, i) => i + 1);
1417
+ }
1418
+ // Object iteration: z-for="(val, key) in obj" → entries
1419
+ if (!Array.isArray(list) && typeof list === 'object' && typeof list[Symbol.iterator] !== 'function') {
1420
+ list = Object.entries(list).map(([k, v]) => ({ key: k, value: v }));
1421
+ }
1422
+ if (!Array.isArray(list) && typeof list[Symbol.iterator] === 'function') {
1423
+ list = [...list];
1424
+ }
1425
+ if (!Array.isArray(list)) { el.remove(); continue; }
1426
+
1427
+ const parent = el.parentNode;
1428
+ const tplEl = el.cloneNode(true);
1429
+ tplEl.removeAttribute('z-for');
1430
+ const tplOuter = tplEl.outerHTML;
1431
+
1432
+ const fragment = document.createDocumentFragment();
1433
+ const evalReplace = (str, item, index) =>
1434
+ str.replace(/\{\{(.+?)\}\}/g, (_, inner) => {
1435
+ try {
1436
+ return new Function(itemVar, indexVar, 'state', 'props', '$',
1437
+ `with(state){return (${inner.trim()})}`)(
1438
+ item, index,
1439
+ this.state.__raw || this.state,
1440
+ this.props,
1441
+ typeof window !== 'undefined' ? window.$ : undefined
1442
+ );
1443
+ } catch { return ''; }
1444
+ });
1445
+
1446
+ for (let i = 0; i < list.length; i++) {
1447
+ const processed = evalReplace(tplOuter, list[i], i);
1448
+ const wrapper = document.createElement('div');
1449
+ wrapper.innerHTML = processed;
1450
+ while (wrapper.firstChild) fragment.appendChild(wrapper.firstChild);
1451
+ }
1452
+
1453
+ parent.replaceChild(fragment, el);
1454
+ }
1455
+
1456
+ // Handle remaining nested z-for (now exposed)
1457
+ if (root.querySelector('[z-for]')) _recurse(root);
1458
+ };
1459
+
1460
+ _recurse(temp);
1461
+ return temp.innerHTML;
1462
+ }
1463
+
1464
+ // ---------------------------------------------------------------------------
1465
+ // _processDirectives — Post-innerHTML DOM-level directive processing
1466
+ // ---------------------------------------------------------------------------
1467
+ _processDirectives() {
1468
+ // z-pre: skip all directive processing on subtrees
1469
+ // (we leave z-pre elements in the DOM, but skip their descendants)
1470
+
1471
+ // -- z-if / z-else-if / z-else (conditional rendering) --------
1472
+ const ifEls = [...this._el.querySelectorAll('[z-if]')];
1473
+ for (const el of ifEls) {
1474
+ if (!el.parentNode || el.closest('[z-pre]')) continue;
1475
+
1476
+ const show = !!this._evalExpr(el.getAttribute('z-if'));
1477
+
1478
+ // Collect chain: adjacent z-else-if / z-else siblings
1479
+ const chain = [{ el, show }];
1480
+ let sib = el.nextElementSibling;
1481
+ while (sib) {
1482
+ if (sib.hasAttribute('z-else-if')) {
1483
+ chain.push({ el: sib, show: !!this._evalExpr(sib.getAttribute('z-else-if')) });
1484
+ sib = sib.nextElementSibling;
1485
+ } else if (sib.hasAttribute('z-else')) {
1486
+ chain.push({ el: sib, show: true });
1487
+ break;
1488
+ } else {
1489
+ break;
1490
+ }
1491
+ }
1492
+
1493
+ // Keep the first truthy branch, remove the rest
1494
+ let found = false;
1495
+ for (const item of chain) {
1496
+ if (!found && item.show) {
1497
+ found = true;
1498
+ item.el.removeAttribute('z-if');
1499
+ item.el.removeAttribute('z-else-if');
1500
+ item.el.removeAttribute('z-else');
1501
+ } else {
1502
+ item.el.remove();
1503
+ }
1504
+ }
1505
+ }
1506
+
1507
+ // -- z-show (toggle display) -----------------------------------
1508
+ this._el.querySelectorAll('[z-show]').forEach(el => {
1509
+ if (el.closest('[z-pre]')) return;
1510
+ const show = !!this._evalExpr(el.getAttribute('z-show'));
1511
+ el.style.display = show ? '' : 'none';
1512
+ el.removeAttribute('z-show');
1513
+ });
1514
+
1515
+ // -- z-bind:attr / :attr (dynamic attribute binding) -----------
1516
+ this._el.querySelectorAll('*').forEach(el => {
1517
+ if (el.closest('[z-pre]')) return;
1518
+ [...el.attributes].forEach(attr => {
1519
+ let attrName;
1520
+ if (attr.name.startsWith('z-bind:')) attrName = attr.name.slice(7);
1521
+ else if (attr.name.startsWith(':') && !attr.name.startsWith('::')) attrName = attr.name.slice(1);
1522
+ else return;
1523
+
1524
+ const val = this._evalExpr(attr.value);
1525
+ el.removeAttribute(attr.name);
1526
+ if (val === false || val === null || val === undefined) {
1527
+ el.removeAttribute(attrName);
1528
+ } else if (val === true) {
1529
+ el.setAttribute(attrName, '');
1530
+ } else {
1531
+ el.setAttribute(attrName, String(val));
1532
+ }
1533
+ });
1534
+ });
1535
+
1536
+ // -- z-class (dynamic class binding) ---------------------------
1537
+ this._el.querySelectorAll('[z-class]').forEach(el => {
1538
+ if (el.closest('[z-pre]')) return;
1539
+ const val = this._evalExpr(el.getAttribute('z-class'));
1540
+ if (typeof val === 'string') {
1541
+ val.split(/\s+/).filter(Boolean).forEach(c => el.classList.add(c));
1542
+ } else if (Array.isArray(val)) {
1543
+ val.filter(Boolean).forEach(c => el.classList.add(String(c)));
1544
+ } else if (val && typeof val === 'object') {
1545
+ for (const [cls, active] of Object.entries(val)) {
1546
+ el.classList.toggle(cls, !!active);
1547
+ }
1548
+ }
1549
+ el.removeAttribute('z-class');
1550
+ });
1551
+
1552
+ // -- z-style (dynamic inline styles) ---------------------------
1553
+ this._el.querySelectorAll('[z-style]').forEach(el => {
1554
+ if (el.closest('[z-pre]')) return;
1555
+ const val = this._evalExpr(el.getAttribute('z-style'));
1556
+ if (typeof val === 'string') {
1557
+ el.style.cssText += ';' + val;
1558
+ } else if (val && typeof val === 'object') {
1559
+ for (const [prop, v] of Object.entries(val)) {
1560
+ el.style[prop] = v;
1561
+ }
1562
+ }
1563
+ el.removeAttribute('z-style');
1564
+ });
1565
+
1566
+ // -- z-html (innerHTML injection) ------------------------------
1567
+ this._el.querySelectorAll('[z-html]').forEach(el => {
1568
+ if (el.closest('[z-pre]')) return;
1569
+ const val = this._evalExpr(el.getAttribute('z-html'));
1570
+ el.innerHTML = val != null ? String(val) : '';
1571
+ el.removeAttribute('z-html');
1572
+ });
1573
+
1574
+ // -- z-text (safe textContent binding) -------------------------
1575
+ this._el.querySelectorAll('[z-text]').forEach(el => {
1576
+ if (el.closest('[z-pre]')) return;
1577
+ const val = this._evalExpr(el.getAttribute('z-text'));
1578
+ el.textContent = val != null ? String(val) : '';
1579
+ el.removeAttribute('z-text');
1580
+ });
1581
+
1582
+ // -- z-cloak (remove after render) -----------------------------
1583
+ this._el.querySelectorAll('[z-cloak]').forEach(el => {
1584
+ el.removeAttribute('z-cloak');
1585
+ });
1586
+ }
1587
+
1224
1588
  // Programmatic state update (batch-friendly)
1589
+ // Passing an empty object forces a re-render (useful for external state changes).
1225
1590
  setState(partial) {
1226
- Object.assign(this.state, partial);
1591
+ if (partial && Object.keys(partial).length > 0) {
1592
+ Object.assign(this.state, partial);
1593
+ } else {
1594
+ this._scheduleUpdate();
1595
+ }
1227
1596
  }
1228
1597
 
1229
1598
  // Emit custom event up the DOM
@@ -1481,9 +1850,9 @@ function style(urls, opts = {}) {
1481
1850
  class Router {
1482
1851
  constructor(config = {}) {
1483
1852
  this._el = null;
1484
- // Auto-detect: file:// protocol can't use pushState, fall back to hash
1853
+ // file:// protocol can't use pushState always force hash mode
1485
1854
  const isFile = typeof location !== 'undefined' && location.protocol === 'file:';
1486
- this._mode = config.mode || (isFile ? 'hash' : 'history');
1855
+ this._mode = isFile ? 'hash' : (config.mode || 'history');
1487
1856
 
1488
1857
  // Base path for sub-path deployments
1489
1858
  // Priority: explicit config.base → window.__ZQ_BASE → <base href> tag
@@ -2450,14 +2819,14 @@ const bus = new EventBus();
2450
2819
 
2451
2820
  // --- index.js (assembly) ——————————————————————————————————————————
2452
2821
  /**
2453
- * ┌─────────────────────────────────────────────────────────┐
2822
+ * ┌---------------------------------------------------------┐
2454
2823
  * │ zQuery (zeroQuery) — Lightweight Frontend Library │
2455
2824
  * │ │
2456
2825
  * │ jQuery-like selectors · Reactive components │
2457
2826
  * │ SPA router · State management · Zero dependencies │
2458
2827
  * │ │
2459
2828
  * │ https://github.com/tonywied17/zero-query │
2460
- * └─────────────────────────────────────────────────────────┘
2829
+ * └---------------------------------------------------------┘
2461
2830
  */
2462
2831
 
2463
2832
 
@@ -2521,6 +2890,7 @@ $.all = function(selector, context) {
2521
2890
  $.create = query.create;
2522
2891
  $.ready = query.ready;
2523
2892
  $.on = query.on;
2893
+ $.off = query.off;
2524
2894
  $.fn = query.fn;
2525
2895
 
2526
2896
  // --- Reactive primitives ---------------------------------------------------
@@ -2576,7 +2946,7 @@ $.session = session;
2576
2946
  $.bus = bus;
2577
2947
 
2578
2948
  // --- Meta ------------------------------------------------------------------
2579
- $.version = '0.2.9';
2949
+ $.version = '0.4.9';
2580
2950
  $.meta = {}; // populated at build time by CLI bundler
2581
2951
 
2582
2952
  $.noConflict = () => {