zero-query 1.0.9 → 1.2.0

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 (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -0
  5. package/cli/commands/build.js +254 -216
  6. package/cli/commands/bundle.js +1228 -1183
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -167
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +7264 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6252
  81. package/dist/zquery.min.js +8 -601
  82. package/index.d.ts +570 -365
  83. package/index.js +311 -232
  84. package/package.json +76 -69
  85. package/src/component.js +1709 -1454
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -254
  93. package/src/router.js +843 -773
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -272
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1023
  115. package/tests/compare.test.js +497 -0
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -0
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -0
  121. package/tests/electron-features.test.js +864 -0
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -145
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
package/src/component.js CHANGED
@@ -1,1454 +1,1709 @@
1
- /**
2
- * zQuery Component - Lightweight reactive component system
3
- *
4
- * Declarative components using template literals with directive support.
5
- * Proxy-based state triggers targeted re-renders via event delegation.
6
- *
7
- * Features:
8
- * - Reactive state (auto re-render on mutation)
9
- * - Template literals with full JS expression power
10
- * - @event="method" syntax for event binding (delegated)
11
- * - z-ref="name" for element references
12
- * - z-model="stateKey" for two-way binding
13
- * - Lifecycle hooks: init, mounted, updated, destroyed
14
- * - Props passed via attributes
15
- * - Scoped styles (inline or via styleUrl)
16
- * - External templates via templateUrl (with {{expression}} interpolation)
17
- * - External styles via styleUrl (fetched & scoped automatically)
18
- * - Relative path resolution - templateUrl and styleUrl
19
- * resolve relative to the component file automatically
20
- */
21
-
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';
26
- import { escapeHtml } from './utils.js';
27
-
28
- // ---------------------------------------------------------------------------
29
- // Component registry & external resource cache
30
- // ---------------------------------------------------------------------------
31
- const _registry = new Map(); // name → definition
32
- const _instances = new Map(); // element → instance
33
- const _resourceCache = new Map(); // url → Promise<string>
34
-
35
- // Unique ID counter
36
- let _uid = 0;
37
-
38
- // Inject z-cloak base style and mobile tap-highlight reset (once, globally)
39
- if (typeof document !== 'undefined' && !document.querySelector('[data-zq-cloak]')) {
40
- const _s = document.createElement('style');
41
- _s.textContent = '[z-cloak]{display:none!important}*,*::before,*::after{-webkit-tap-highlight-color:transparent}';
42
- _s.setAttribute('data-zq-cloak', '');
43
- document.head.appendChild(_s);
44
- }
45
-
46
- // Debounce / throttle helpers for event modifiers
47
- const _debounceTimers = new WeakMap();
48
- const _throttleTimers = new WeakMap();
49
-
50
- /**
51
- * Fetch and cache a text resource (HTML template or CSS file).
52
- * @param {string} url - URL to fetch
53
- * @returns {Promise<string>}
54
- */
55
- function _fetchResource(url) {
56
- if (_resourceCache.has(url)) return _resourceCache.get(url);
57
-
58
- // Check inline resource map (populated by CLI bundler for file:// support).
59
- // Keys are relative paths; match against the URL suffix.
60
- if (typeof window !== 'undefined' && window.__zqInline) {
61
- for (const [path, content] of Object.entries(window.__zqInline)) {
62
- if (url === path || url.endsWith('/' + path) || url.endsWith('\\' + path)) {
63
- const resolved = Promise.resolve(content);
64
- _resourceCache.set(url, resolved);
65
- return resolved;
66
- }
67
- }
68
- }
69
-
70
- // Resolve relative URLs against <base href> or origin root.
71
- // This prevents SPA route paths (e.g. /docs/advanced) from
72
- // breaking relative resource URLs like 'scripts/components/foo.css'.
73
- let resolvedUrl = url;
74
- if (typeof url === 'string' && !url.startsWith('/') && !url.includes(':') && !url.startsWith('//')) {
75
- try {
76
- const baseEl = document.querySelector('base');
77
- const root = baseEl ? baseEl.href : (window.location.origin + '/');
78
- resolvedUrl = new URL(url, root).href;
79
- } catch { /* keep original */ }
80
- }
81
-
82
- const promise = fetch(resolvedUrl).then(res => {
83
- if (!res.ok) throw new Error(`zQuery: Failed to load resource "${url}" (${res.status})`);
84
- return res.text();
85
- });
86
- _resourceCache.set(url, promise);
87
- return promise;
88
- }
89
-
90
- /**
91
- * Resolve a relative URL against a base.
92
- *
93
- * - If `base` is an absolute URL (http/https/file), resolve directly.
94
- * - If `base` is a relative path string, resolve it against the page root
95
- * (or <base href>) first, then resolve `url` against that.
96
- * - If `base` is falsy, return `url` unchanged - _fetchResource's own
97
- * fallback (page root / <base href>) handles it.
98
- *
99
- * @param {string} url - URL or relative path to resolve
100
- * @param {string} [base] - auto-detected caller URL or explicit base path
101
- * @returns {string}
102
- */
103
- function _resolveUrl(url, base) {
104
- if (!base || !url || typeof url !== 'string') return url;
105
- // Already absolute - nothing to do
106
- if (url.startsWith('/') || url.includes('://') || url.startsWith('//')) return url;
107
- try {
108
- if (base.includes('://')) {
109
- // Absolute base (auto-detected module URL)
110
- return new URL(url, base).href;
111
- }
112
- // Relative base string - resolve against page root first
113
- const baseEl = document.querySelector('base');
114
- const root = baseEl ? baseEl.href : (window.location.origin + '/');
115
- const absBase = new URL(base.endsWith('/') ? base : base + '/', root).href;
116
- return new URL(url, absBase).href;
117
- } catch {
118
- return url;
119
- }
120
- }
121
-
122
- // Capture the library's own script URL at load time for reliable filtering.
123
- // This handles cases where the bundle is renamed (e.g., 'vendor.js').
124
- let _ownScriptUrl;
125
- try {
126
- if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
127
- _ownScriptUrl = document.currentScript.src.replace(/[?#].*$/, '');
128
- }
129
- } catch { /* ignored */ }
130
-
131
- /**
132
- * Detect the URL of the module that called $.component().
133
- * Parses Error().stack to find the first frame outside the zQuery bundle.
134
- * Returns the directory URL (with trailing slash) or undefined.
135
- * @returns {string|undefined}
136
- */
137
- function _detectCallerBase() {
138
- try {
139
- const stack = new Error().stack || '';
140
- const urls = stack.match(/(?:https?|file):\/\/[^\s\)]+/g) || [];
141
- for (const raw of urls) {
142
- // Strip line:col suffixes e.g. ":3:5" or ":12:1"
143
- const url = raw.replace(/:\d+:\d+$/, '').replace(/:\d+$/, '');
144
- // Skip the zQuery library itself - by filename pattern and captured URL
145
- if (/zquery(\.min)?\.js$/i.test(url)) continue;
146
- if (_ownScriptUrl && url.replace(/[?#].*$/, '') === _ownScriptUrl) continue;
147
- // Return directory (strip filename, keep trailing slash)
148
- return url.replace(/\/[^/]*$/, '/');
149
- }
150
- } catch { /* stack parsing unsupported - fall back silently */ }
151
- return undefined;
152
- }
153
-
154
- /**
155
- * Get a value from a nested object by dot-path.
156
- * _getPath(obj, 'user.name') → obj.user.name
157
- * @param {object} obj
158
- * @param {string} path
159
- * @returns {*}
160
- */
161
- function _getPath(obj, path) {
162
- return path.split('.').reduce((o, k) => o?.[k], obj);
163
- }
164
-
165
- /**
166
- * Set a value on a nested object by dot-path, walking through proxy layers.
167
- * _setPath(proxy, 'user.name', 'Tony') → proxy.user.name = 'Tony'
168
- * @param {object} obj
169
- * @param {string} path
170
- * @param {*} value
171
- */
172
- function _setPath(obj, path, value) {
173
- const keys = path.split('.');
174
- const last = keys.pop();
175
- const target = keys.reduce((o, k) => (o && typeof o === 'object') ? o[k] : undefined, obj);
176
- if (target && typeof target === 'object') target[last] = value;
177
- }
178
-
179
-
180
- // ---------------------------------------------------------------------------
181
- // Component class
182
- // ---------------------------------------------------------------------------
183
- class Component {
184
- constructor(el, definition, props = {}) {
185
- this._uid = ++_uid;
186
- this._el = el;
187
- this._def = definition;
188
- this._mounted = false;
189
- this._destroyed = false;
190
- this._updateQueued = false;
191
- this._listeners = [];
192
- this._watchCleanups = [];
193
-
194
- // Refs map
195
- this.refs = {};
196
-
197
- // Capture slot content before first render replaces it
198
- this._slotContent = {};
199
- const defaultSlotNodes = [];
200
- [...el.childNodes].forEach(node => {
201
- if (node.nodeType === 1 && node.hasAttribute('slot')) {
202
- const slotName = node.getAttribute('slot') || 'default';
203
- if (!this._slotContent[slotName]) this._slotContent[slotName] = '';
204
- this._slotContent[slotName] += node.outerHTML;
205
- } else if (node.nodeType === 1 || (node.nodeType === 3 && node.textContent.trim())) {
206
- defaultSlotNodes.push(node.nodeType === 1 ? node.outerHTML : node.textContent);
207
- }
208
- });
209
- if (defaultSlotNodes.length) {
210
- this._slotContent['default'] = defaultSlotNodes.join('');
211
- }
212
-
213
- // Props (read-only from parent)
214
- this.props = Object.freeze({ ...props });
215
-
216
- // Reactive state
217
- const initialState = typeof definition.state === 'function'
218
- ? definition.state()
219
- : { ...(definition.state || {}) };
220
-
221
- this.state = reactive(initialState, (key, value, old) => {
222
- if (!this._destroyed) {
223
- // Run watchers for the changed key
224
- this._runWatchers(key, value, old);
225
- this._scheduleUpdate();
226
- }
227
- });
228
-
229
- // Computed properties - lazy getters derived from state
230
- this.computed = {};
231
- if (definition.computed) {
232
- for (const [name, fn] of Object.entries(definition.computed)) {
233
- Object.defineProperty(this.computed, name, {
234
- get: () => fn.call(this, this.state.__raw || this.state),
235
- enumerable: true
236
- });
237
- }
238
- }
239
-
240
- // Bind all user methods to this instance
241
- for (const [key, val] of Object.entries(definition)) {
242
- if (typeof val === 'function' && !_reservedKeys.has(key)) {
243
- this[key] = val.bind(this);
244
- }
245
- }
246
-
247
- // Init lifecycle
248
- if (definition.init) {
249
- try { definition.init.call(this); }
250
- catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${definition._name}" init() threw`, { component: definition._name }, err); }
251
- }
252
-
253
- // Set up watchers after init so initial state is ready
254
- if (definition.watch) {
255
- this._prevWatchValues = {};
256
- for (const key of Object.keys(definition.watch)) {
257
- this._prevWatchValues[key] = _getPath(this.state.__raw || this.state, key);
258
- }
259
- }
260
- }
261
-
262
- // Run registered watchers for a changed key
263
- _runWatchers(changedKey, value, old) {
264
- const watchers = this._def.watch;
265
- if (!watchers) return;
266
- for (const [key, handler] of Object.entries(watchers)) {
267
- // Match exact key or parent key (e.g. watcher on 'user' fires when 'user.name' changes)
268
- if (changedKey === key || key.startsWith(changedKey + '.') || changedKey.startsWith(key + '.')) {
269
- const currentVal = _getPath(this.state.__raw || this.state, key);
270
- const prevVal = this._prevWatchValues?.[key];
271
- if (currentVal !== prevVal) {
272
- const fn = typeof handler === 'function' ? handler : handler.handler;
273
- if (typeof fn === 'function') fn.call(this, currentVal, prevVal);
274
- if (this._prevWatchValues) this._prevWatchValues[key] = currentVal;
275
- }
276
- }
277
- }
278
- }
279
-
280
- // Schedule a batched DOM update (microtask)
281
- _scheduleUpdate() {
282
- if (this._updateQueued) return;
283
- this._updateQueued = true;
284
- queueMicrotask(() => {
285
- try {
286
- if (!this._destroyed) this._render();
287
- } finally {
288
- this._updateQueued = false;
289
- }
290
- });
291
- }
292
-
293
- // Load external templateUrl / styleUrl if specified (once per definition)
294
- //
295
- // Relative paths are resolved automatically against the component file's
296
- // own directory (auto-detected at registration time). You can override
297
- // this with `base: 'some/path/'` on the definition.
298
- //
299
- // templateUrl accepts:
300
- // - string → single template (used with {{expr}} interpolation)
301
- // - string[] → array of URLs → indexed map via this.templates[0], …
302
- // - { key: url, … } → named map → this.templates.key
303
- //
304
- // styleUrl accepts:
305
- // - string → single stylesheet
306
- // - string[] → array of URLs → all fetched & concatenated
307
- //
308
- async _loadExternals() {
309
- const def = this._def;
310
- const base = def._base; // auto-detected or explicit
311
-
312
- // -- External templates --------------------------------------
313
- if (def.templateUrl && !def._templateLoaded) {
314
- const tu = def.templateUrl;
315
- if (typeof tu === 'string') {
316
- def._externalTemplate = await _fetchResource(_resolveUrl(tu, base));
317
- } else if (Array.isArray(tu)) {
318
- const urls = tu.map(u => _resolveUrl(u, base));
319
- const results = await Promise.all(urls.map(u => _fetchResource(u)));
320
- def._externalTemplates = {};
321
- results.forEach((html, i) => { def._externalTemplates[i] = html; });
322
- } else if (typeof tu === 'object') {
323
- const entries = Object.entries(tu);
324
- const results = await Promise.all(
325
- entries.map(([, url]) => _fetchResource(_resolveUrl(url, base)))
326
- );
327
- def._externalTemplates = {};
328
- entries.forEach(([key], i) => { def._externalTemplates[key] = results[i]; });
329
- }
330
- def._templateLoaded = true;
331
- }
332
-
333
- // -- External styles -----------------------------------------
334
- if (def.styleUrl && !def._styleLoaded) {
335
- const su = def.styleUrl;
336
- if (typeof su === 'string') {
337
- const resolved = _resolveUrl(su, base);
338
- def._externalStyles = await _fetchResource(resolved);
339
- def._resolvedStyleUrls = [resolved];
340
- } else if (Array.isArray(su)) {
341
- const urls = su.map(u => _resolveUrl(u, base));
342
- const results = await Promise.all(urls.map(u => _fetchResource(u)));
343
- def._externalStyles = results.join('\n');
344
- def._resolvedStyleUrls = urls;
345
- }
346
- def._styleLoaded = true;
347
- }
348
- }
349
-
350
- // Render the component
351
- _render() {
352
- // If externals haven't loaded yet, trigger async load then re-render
353
- if ((this._def.templateUrl && !this._def._templateLoaded) ||
354
- (this._def.styleUrl && !this._def._styleLoaded)) {
355
- this._loadExternals().then(() => {
356
- if (!this._destroyed) this._render();
357
- });
358
- return; // Skip this render - will re-render after load
359
- }
360
-
361
- // Expose multi-template map on instance (if available)
362
- if (this._def._externalTemplates) {
363
- this.templates = this._def._externalTemplates;
364
- }
365
-
366
- // Determine HTML content
367
- let html;
368
- if (this._def.render) {
369
- // Inline render function takes priority
370
- html = this._def.render.call(this);
371
- // Expand z-for in render templates ({{}} expressions for iteration items)
372
- html = this._expandZFor(html);
373
- } else if (this._def._externalTemplate) {
374
- // Expand z-for FIRST (before global {{}} interpolation)
375
- html = this._expandZFor(this._def._externalTemplate);
376
- // Then do global {{expression}} interpolation on the remaining content
377
- html = html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
378
- try {
379
- const result = safeEval(expr.trim(), [
380
- this.state.__raw || this.state,
381
- { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
382
- ]);
383
- return result != null ? escapeHtml(String(result)) : '';
384
- } catch { return ''; }
385
- });
386
- } else {
387
- html = '';
388
- }
389
-
390
- // Pre-expand z-html and z-text at string level so the morph engine
391
- // can diff their content properly (instead of clearing + re-injecting
392
- // on every re-render). Same pattern as z-for: parse → evaluate → serialize.
393
- html = this._expandContentDirectives(html);
394
-
395
- // -- Slot distribution ----------------------------------------
396
- // Replace <slot> elements with captured slot content from parent.
397
- // <slot> → default slot content
398
- // <slot name="header"> named slot content
399
- // Fallback content between <slot>...</slot> used when no content provided.
400
- if (html.includes('<slot')) {
401
- html = html.replace(/<slot(?:\s+name="([^"]*)")?\s*(?:\/>|>([\s\S]*?)<\/slot>)/g, (_, name, fallback) => {
402
- const slotName = name || 'default';
403
- return this._slotContent[slotName] || fallback || '';
404
- });
405
- }
406
-
407
- // Combine inline styles + external styles
408
- const combinedStyles = [
409
- this._def.styles || '',
410
- this._def._externalStyles || ''
411
- ].filter(Boolean).join('\n');
412
-
413
- // Apply scoped styles on first render
414
- if (!this._mounted && combinedStyles) {
415
- const scopeAttr = `z-s${this._uid}`;
416
- this._el.setAttribute(scopeAttr, '');
417
- let noScopeDepth = 0; // brace depth at which a no-scope @-rule started (0 = none active)
418
- let braceDepth = 0; // overall brace depth
419
- const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
420
- if (match === '}') {
421
- if (noScopeDepth > 0 && braceDepth <= noScopeDepth) noScopeDepth = 0;
422
- braceDepth--;
423
- return match;
424
- }
425
- braceDepth++;
426
- const trimmed = selector.trim();
427
- // Don't scope @-rules themselves
428
- if (trimmed.startsWith('@')) {
429
- // @keyframes and @font-face contain non-selector content - skip scoping inside them
430
- if (/^@(keyframes|font-face)\b/.test(trimmed)) {
431
- noScopeDepth = braceDepth;
432
- }
433
- return match;
434
- }
435
- // Inside @keyframes or @font-face - don't scope inner rules
436
- if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
437
- return match;
438
- }
439
- return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
440
- });
441
- const styleEl = document.createElement('style');
442
- styleEl.textContent = scoped;
443
- styleEl.setAttribute('data-zq-component', this._def._name || '');
444
- styleEl.setAttribute('data-zq-scope', scopeAttr);
445
- if (this._def._resolvedStyleUrls) {
446
- styleEl.setAttribute('data-zq-style-urls', this._def._resolvedStyleUrls.join(' '));
447
- if (this._def.styles) {
448
- styleEl.setAttribute('data-zq-inline', this._def.styles);
449
- }
450
- }
451
- document.head.appendChild(styleEl);
452
- this._styleEl = styleEl;
453
- }
454
-
455
- // -- Focus preservation ----------------------------------------
456
- // DOM morphing preserves unchanged nodes naturally, but we still
457
- // track focus for cases where the focused element's subtree changes.
458
- let _focusInfo = null;
459
- const _active = document.activeElement;
460
- if (_active && this._el.contains(_active)) {
461
- const modelKey = _active.getAttribute?.('z-model');
462
- const refKey = _active.getAttribute?.('z-ref');
463
- let selector = null;
464
- if (modelKey) {
465
- selector = `[z-model="${modelKey}"]`;
466
- } else if (refKey) {
467
- selector = `[z-ref="${refKey}"]`;
468
- } else {
469
- const tag = _active.tagName.toLowerCase();
470
- if (tag === 'input' || tag === 'textarea' || tag === 'select') {
471
- let s = tag;
472
- if (_active.type) s += `[type="${_active.type}"]`;
473
- if (_active.name) s += `[name="${_active.name}"]`;
474
- if (_active.placeholder) s += `[placeholder="${CSS.escape(_active.placeholder)}"]`;
475
- selector = s;
476
- }
477
- }
478
- if (selector) {
479
- _focusInfo = {
480
- selector,
481
- start: _active.selectionStart,
482
- end: _active.selectionEnd,
483
- dir: _active.selectionDirection,
484
- };
485
- }
486
- }
487
-
488
- // Update DOM via morphing (diffing) - preserves unchanged nodes
489
- // First render uses innerHTML for speed; subsequent renders morph.
490
- const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
491
- if (!this._mounted) {
492
- this._el.innerHTML = html;
493
- if (_renderStart && window.__zqRenderHook) window.__zqRenderHook(this._el, performance.now() - _renderStart, 'mount', this._def._name);
494
- } else {
495
- morph(this._el, html);
496
- }
497
-
498
- // Process structural & attribute directives
499
- this._processDirectives();
500
-
501
- // Process event, ref, and model bindings
502
- this._bindEvents();
503
- this._bindRefs();
504
- this._bindModels();
505
-
506
- // Restore focus if the morph replaced the focused element.
507
- // Always restore selectionRange - even when the element is still
508
- // the activeElement - because _bindModels or morph attribute syncing
509
- // can alter the value and move the cursor.
510
- if (_focusInfo) {
511
- const el = this._el.querySelector(_focusInfo.selector);
512
- if (el) {
513
- if (el !== document.activeElement) el.focus();
514
- try {
515
- if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
516
- el.setSelectionRange(_focusInfo.start, _focusInfo.end, _focusInfo.dir);
517
- }
518
- } catch (_) { /* some input types don't support setSelectionRange */ }
519
- }
520
- }
521
-
522
- // Mount nested components
523
- mountAll(this._el);
524
-
525
- if (!this._mounted) {
526
- this._mounted = true;
527
- if (this._def.mounted) {
528
- try { this._def.mounted.call(this); }
529
- catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" mounted() threw`, { component: this._def._name }, err); }
530
- }
531
- } else {
532
- if (this._def.updated) {
533
- try { this._def.updated.call(this); }
534
- catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" updated() threw`, { component: this._def._name }, err); }
535
- }
536
- }
537
- }
538
-
539
- // Bind @event="method" and z-on:event="method" handlers via delegation.
540
- //
541
- // Optimization: on the FIRST render, we scan for event attributes, build
542
- // a delegated handler map, and attach one listener per event type to the
543
- // component root. On subsequent renders (re-bind), we only rebuild the
544
- // internal binding map - existing DOM listeners are reused since they
545
- // delegate to event.target.closest(selector) at fire time.
546
- _bindEvents() {
547
- // Always rebuild the binding map from current DOM
548
- const eventMap = new Map(); // event → [{ selector, methodExpr, modifiers, el }]
549
-
550
- const allEls = this._el.querySelectorAll('*');
551
- allEls.forEach(child => {
552
- if (child.closest('[z-pre]')) return;
553
-
554
- const attrs = child.attributes;
555
- for (let a = 0; a < attrs.length; a++) {
556
- const attr = attrs[a];
557
- let raw;
558
- if (attr.name.charCodeAt(0) === 64) { // '@'
559
- raw = attr.name.slice(1);
560
- } else if (attr.name.startsWith('z-on:')) {
561
- raw = attr.name.slice(5);
562
- } else {
563
- continue;
564
- }
565
-
566
- const parts = raw.split('.');
567
- const event = parts[0];
568
- const modifiers = parts.slice(1);
569
- const methodExpr = attr.value;
570
-
571
- // Give element a unique selector for delegation
572
- if (!child.dataset.zqEid) {
573
- child.dataset.zqEid = String(++_uid);
574
- }
575
- const selector = `[data-zq-eid="${child.dataset.zqEid}"]`;
576
-
577
- if (!eventMap.has(event)) eventMap.set(event, []);
578
- eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
579
- }
580
- });
581
-
582
- // Store binding map for the delegated handlers to reference
583
- this._eventBindings = eventMap;
584
-
585
- // Only attach DOM listeners once - reuse on subsequent renders.
586
- // The handlers close over `this` and read `this._eventBindings`
587
- // at fire time, so they always use the latest binding map.
588
- if (this._delegatedEvents) {
589
- // Already attached - just make sure new event types are covered
590
- for (const event of eventMap.keys()) {
591
- if (!this._delegatedEvents.has(event)) {
592
- this._attachDelegatedEvent(event, eventMap.get(event));
593
- }
594
- }
595
- // Remove listeners for event types no longer in the template
596
- for (const event of this._delegatedEvents.keys()) {
597
- if (!eventMap.has(event)) {
598
- const { handler, opts } = this._delegatedEvents.get(event);
599
- this._el.removeEventListener(event, handler, opts);
600
- this._delegatedEvents.delete(event);
601
- // Also remove from _listeners array
602
- this._listeners = this._listeners.filter(l => l.event !== event);
603
- }
604
- }
605
- return;
606
- }
607
-
608
- this._delegatedEvents = new Map();
609
-
610
- // Register delegated listeners on the component root
611
- for (const [event, bindings] of eventMap) {
612
- this._attachDelegatedEvent(event, bindings);
613
- }
614
-
615
- // .outside - attach a document-level listener for bindings that need
616
- // to detect clicks/events outside their element.
617
- this._outsideListeners = this._outsideListeners || [];
618
- for (const [event, bindings] of eventMap) {
619
- for (const binding of bindings) {
620
- if (!binding.modifiers.includes('outside')) continue;
621
- const outsideHandler = (e) => {
622
- if (binding.el.contains(e.target)) return;
623
- const match = binding.methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
624
- if (!match) return;
625
- const fn = this[match[1]];
626
- if (typeof fn === 'function') fn.call(this, e);
627
- };
628
- document.addEventListener(event, outsideHandler, true);
629
- this._outsideListeners.push({ event, handler: outsideHandler });
630
- }
631
- }
632
- }
633
-
634
- // Attach a single delegated listener for an event type
635
- _attachDelegatedEvent(event, bindings) {
636
- const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
637
- const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
638
- const listenerOpts = (needsCapture || needsPassive)
639
- ? { capture: needsCapture, passive: needsPassive }
640
- : false;
641
-
642
- const handler = (e) => {
643
- // Read bindings from live map - always up to date after re-renders
644
- const currentBindings = this._eventBindings?.get(event) || [];
645
-
646
- // Collect matching bindings with their matched elements, then sort
647
- // deepest-first so .stop correctly prevents ancestor handlers
648
- // (mimics real DOM bubbling order within delegated events).
649
- const hits = [];
650
- for (const binding of currentBindings) {
651
- const matched = e.target.closest(binding.selector);
652
- if (!matched) continue;
653
- hits.push({ ...binding, matched });
654
- }
655
- hits.sort((a, b) => {
656
- if (a.matched === b.matched) return 0;
657
- return a.matched.contains(b.matched) ? 1 : -1;
658
- });
659
-
660
- let stoppedAt = null; // Track elements that called .stop
661
- for (const { selector, methodExpr, modifiers, el, matched } of hits) {
662
-
663
- // In delegated events, .stop should prevent ancestor bindings from
664
- // firing - stopPropagation alone only stops real DOM bubbling.
665
- if (stoppedAt) {
666
- let blocked = false;
667
- for (const stopped of stoppedAt) {
668
- if (matched.contains(stopped) && matched !== stopped) { blocked = true; break; }
669
- }
670
- if (blocked) continue;
671
- }
672
-
673
- // .self - only fire if target is the element itself
674
- if (modifiers.includes('self') && e.target !== el) continue;
675
-
676
- // .outside - only fire if event target is OUTSIDE the element
677
- if (modifiers.includes('outside')) {
678
- if (el.contains(e.target)) continue;
679
- }
680
-
681
- // Key modifiers - filter keyboard events by key.
682
- // Named shortcuts map common names to their e.key values.
683
- // Any modifier not recognised as a built-in behaviour, timing,
684
- // or system modifier is matched against e.key (case-insensitive)
685
- // so that arbitrary keys work: .a, .f1, .+, .0, .arrowup, etc.
686
- const _keyMap = { enter: 'Enter', escape: 'Escape', tab: 'Tab', space: ' ', delete: 'Delete|Backspace', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
687
- const _nonKeyMods = new Set(['prevent','stop','self','once','outside','capture','passive','debounce','throttle','ctrl','shift','alt','meta']);
688
- let keyFiltered = false;
689
- for (let mi = 0; mi < modifiers.length; mi++) {
690
- const mod = modifiers[mi];
691
- if (_keyMap[mod]) {
692
- const keys = _keyMap[mod].split('|');
693
- if (!e.key || !keys.includes(e.key)) { keyFiltered = true; break; }
694
- } else if (_nonKeyMods.has(mod)) {
695
- continue;
696
- } else if (/^\d+$/.test(mod) && mi > 0 && (modifiers[mi - 1] === 'debounce' || modifiers[mi - 1] === 'throttle')) {
697
- // Numeric value following debounce/throttle — skip (it's a ms parameter)
698
- continue;
699
- } else {
700
- // Dynamic key match compare modifier against e.key
701
- // Case-insensitive: .a matches 'a' and 'A', .f1 matches 'F1'
702
- if (!e.key || e.key.toLowerCase() !== mod.toLowerCase()) { keyFiltered = true; break; }
703
- }
704
- }
705
- if (keyFiltered) continue;
706
-
707
- // System key modifiers - require modifier keys to be held
708
- if (modifiers.includes('ctrl') && !e.ctrlKey) continue;
709
- if (modifiers.includes('shift') && !e.shiftKey) continue;
710
- if (modifiers.includes('alt') && !e.altKey) continue;
711
- if (modifiers.includes('meta') && !e.metaKey) continue;
712
-
713
- // Handle modifiers
714
- if (modifiers.includes('prevent')) e.preventDefault();
715
- if (modifiers.includes('stop')) {
716
- e.stopPropagation();
717
- if (!stoppedAt) stoppedAt = [];
718
- stoppedAt.push(matched);
719
- }
720
-
721
- // Build the invocation function
722
- const invoke = (evt) => {
723
- const match = methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
724
- if (!match) return;
725
- const methodName = match[1];
726
- const fn = this[methodName];
727
- if (typeof fn !== 'function') return;
728
- if (match[2] !== undefined) {
729
- const args = match[2].split(',').map(a => {
730
- a = a.trim();
731
- if (a === '') return undefined;
732
- if (a === '$event') return evt;
733
- if (a === 'true') return true;
734
- if (a === 'false') return false;
735
- if (a === 'null') return null;
736
- if (/^-?\d+(\.\d+)?$/.test(a)) return Number(a);
737
- if ((a.startsWith("'") && a.endsWith("'")) || (a.startsWith('"') && a.endsWith('"'))) return a.slice(1, -1);
738
- if (a.startsWith('state.')) return _getPath(this.state, a.slice(6));
739
- return a;
740
- }).filter(a => a !== undefined);
741
- fn(...args);
742
- } else {
743
- fn(evt);
744
- }
745
- };
746
-
747
- // .debounce.{ms} - delay invocation until idle
748
- const debounceIdx = modifiers.indexOf('debounce');
749
- if (debounceIdx !== -1) {
750
- const ms = parseInt(modifiers[debounceIdx + 1], 10) || 250;
751
- const timers = _debounceTimers.get(el) || {};
752
- clearTimeout(timers[event]);
753
- timers[event] = setTimeout(() => invoke(e), ms);
754
- _debounceTimers.set(el, timers);
755
- continue;
756
- }
757
-
758
- // .throttle.{ms} - fire at most once per interval
759
- const throttleIdx = modifiers.indexOf('throttle');
760
- if (throttleIdx !== -1) {
761
- const ms = parseInt(modifiers[throttleIdx + 1], 10) || 250;
762
- const timers = _throttleTimers.get(el) || {};
763
- if (timers[event]) continue;
764
- invoke(e);
765
- timers[event] = setTimeout(() => { timers[event] = null; }, ms);
766
- _throttleTimers.set(el, timers);
767
- continue;
768
- }
769
-
770
- // .once - fire once then ignore
771
- if (modifiers.includes('once')) {
772
- if (el.dataset.zqOnce === event) continue;
773
- el.dataset.zqOnce = event;
774
- }
775
-
776
- invoke(e);
777
- }
778
- };
779
- this._el.addEventListener(event, handler, listenerOpts);
780
- this._listeners.push({ event, handler });
781
- this._delegatedEvents.set(event, { handler, opts: listenerOpts });
782
- }
783
-
784
- // Bind z-ref="name" → this.refs.name
785
- _bindRefs() {
786
- this.refs = {};
787
- this._el.querySelectorAll('[z-ref]').forEach(el => {
788
- this.refs[el.getAttribute('z-ref')] = el;
789
- });
790
- }
791
-
792
- // Bind z-model="stateKey" for two-way binding
793
- //
794
- // Supported elements: input (text, number, range, checkbox, radio, date, color, ),
795
- // textarea, select (single & multiple), contenteditable
796
- // Nested state keys: z-model="user.name" → this.state.user.name
797
- // Modifiers (boolean attributes on the same element):
798
- // z-lazy - listen on 'change' instead of 'input' (update on blur / commit)
799
- // z-trim - trim whitespace before writing to state
800
- // z-number - force Number() conversion regardless of input type
801
- // z-debounce - debounce state writes (default 250ms, or z-debounce="300")
802
- // z-uppercase - convert string to uppercase before writing to state
803
- // z-lowercase - convert string to lowercase before writing to state
804
- //
805
- // Writes to reactive state so the rest of the UI stays in sync.
806
- // Focus and cursor position are preserved in _render() via focusInfo.
807
- //
808
- _bindModels() {
809
- this._el.querySelectorAll('[z-model]').forEach(el => {
810
- const key = el.getAttribute('z-model');
811
- const tag = el.tagName.toLowerCase();
812
- const type = (el.type || '').toLowerCase();
813
- const isEditable = el.hasAttribute('contenteditable');
814
-
815
- // Modifiers
816
- const isLazy = el.hasAttribute('z-lazy');
817
- const isTrim = el.hasAttribute('z-trim');
818
- const isNum = el.hasAttribute('z-number');
819
- const isUpper = el.hasAttribute('z-uppercase');
820
- const isLower = el.hasAttribute('z-lowercase');
821
- const hasDebounce = el.hasAttribute('z-debounce');
822
- const debounceMs = hasDebounce ? (parseInt(el.getAttribute('z-debounce'), 10) || 250) : 0;
823
-
824
- // Read current state value (supports dot-path keys)
825
- const currentVal = _getPath(this.state, key);
826
-
827
- // -- Set initial DOM value from state (always sync) ----------
828
- if (tag === 'input' && type === 'checkbox') {
829
- el.checked = !!currentVal;
830
- } else if (tag === 'input' && type === 'radio') {
831
- el.checked = el.value === String(currentVal);
832
- } else if (tag === 'select' && el.multiple) {
833
- const vals = Array.isArray(currentVal) ? currentVal.map(String) : [];
834
- [...el.options].forEach(opt => { opt.selected = vals.includes(opt.value); });
835
- } else if (isEditable) {
836
- if (el.textContent !== String(currentVal ?? '')) {
837
- el.textContent = currentVal ?? '';
838
- }
839
- } else {
840
- el.value = currentVal ?? '';
841
- }
842
-
843
- // -- Determine event type ------------------------------------
844
- const event = isLazy || tag === 'select' || type === 'checkbox' || type === 'radio'
845
- ? 'change'
846
- : isEditable ? 'input' : 'input';
847
-
848
- // -- Handler: read DOM → write to reactive state -------------
849
- // Skip if already bound (morph preserves existing elements,
850
- // so re-binding would stack duplicate listeners)
851
- if (el._zqModelBound) return;
852
- el._zqModelBound = true;
853
-
854
- const handler = () => {
855
- let val;
856
- if (type === 'checkbox') val = el.checked;
857
- else if (tag === 'select' && el.multiple) val = [...el.selectedOptions].map(o => o.value);
858
- else if (isEditable) val = el.textContent;
859
- else val = el.value;
860
-
861
- // Apply modifiers
862
- if (isTrim && typeof val === 'string') val = val.trim();
863
- if (isUpper && typeof val === 'string') val = val.toUpperCase();
864
- if (isLower && typeof val === 'string') val = val.toLowerCase();
865
- if (isNum || type === 'number' || type === 'range') val = Number(val);
866
-
867
- // Write through the reactive proxy (triggers re-render).
868
- // Focus + cursor are preserved automatically by _render().
869
- _setPath(this.state, key, val);
870
- };
871
-
872
- if (hasDebounce) {
873
- let timer = null;
874
- el.addEventListener(event, () => {
875
- clearTimeout(timer);
876
- timer = setTimeout(handler, debounceMs);
877
- });
878
- } else {
879
- el.addEventListener(event, handler);
880
- }
881
- });
882
- }
883
-
884
- // ---------------------------------------------------------------------------
885
- // Expression evaluator - CSP-safe parser (no eval / new Function)
886
- // ---------------------------------------------------------------------------
887
- _evalExpr(expr) {
888
- return safeEval(expr, [
889
- this.state.__raw || this.state,
890
- { props: this.props, refs: this.refs, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
891
- ]);
892
- }
893
-
894
- // ---------------------------------------------------------------------------
895
- // z-for - Expand list-rendering directives (pre-innerHTML, string level)
896
- //
897
- // <li z-for="item in items">{{item.name}}</li>
898
- // <li z-for="(item, i) in items">{{i}}: {{item.name}}</li>
899
- // <div z-for="n in 5">{{n}}</div> (range)
900
- // <div z-for="(val, key) in obj">{{key}}: {{val}}</div> (object)
901
- //
902
- // Uses a temporary DOM to parse, clone elements per item, and evaluate
903
- // {{}} expressions with the iteration variable in scope.
904
- // ---------------------------------------------------------------------------
905
- _expandZFor(html) {
906
- if (!html.includes('z-for')) return html;
907
-
908
- const temp = document.createElement('div');
909
- temp.innerHTML = html;
910
-
911
- const _recurse = (root) => {
912
- // Process innermost z-for elements first (no nested z-for inside)
913
- let forEls = [...root.querySelectorAll('[z-for]')]
914
- .filter(el => !el.querySelector('[z-for]'));
915
- if (!forEls.length) return;
916
-
917
- for (const el of forEls) {
918
- if (!el.parentNode) continue; // already removed
919
- const expr = el.getAttribute('z-for');
920
- const m = expr.match(
921
- /^\s*(?:\(\s*(\w+)(?:\s*,\s*(\w+))?\s*\)|(\w+))\s+in\s+(.+)\s*$/
922
- );
923
- if (!m) { el.removeAttribute('z-for'); continue; }
924
-
925
- const itemVar = m[1] || m[3];
926
- const indexVar = m[2] || '$index';
927
- const listExpr = m[4].trim();
928
-
929
- let list = this._evalExpr(listExpr);
930
- if (list == null) { el.remove(); continue; }
931
- // Number range: z-for="n in 5" → [1, 2, 3, 4, 5]
932
- if (typeof list === 'number') {
933
- list = Array.from({ length: list }, (_, i) => i + 1);
934
- }
935
- // Object iteration: z-for="(val, key) in obj" entries
936
- if (!Array.isArray(list) && typeof list === 'object' && typeof list[Symbol.iterator] !== 'function') {
937
- list = Object.entries(list).map(([k, v]) => ({ key: k, value: v }));
938
- }
939
- if (!Array.isArray(list) && typeof list[Symbol.iterator] === 'function') {
940
- list = [...list];
941
- }
942
- if (!Array.isArray(list)) { el.remove(); continue; }
943
-
944
- const parent = el.parentNode;
945
- const tplEl = el.cloneNode(true);
946
- tplEl.removeAttribute('z-for');
947
- const tplOuter = tplEl.outerHTML;
948
-
949
- const fragment = document.createDocumentFragment();
950
- const evalReplace = (str, item, index) =>
951
- str.replace(/\{\{(.+?)\}\}/g, (_, inner) => {
952
- try {
953
- const loopScope = {};
954
- loopScope[itemVar] = item;
955
- loopScope[indexVar] = index;
956
- const result = safeEval(inner.trim(), [
957
- loopScope,
958
- this.state.__raw || this.state,
959
- { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
960
- ]);
961
- return result != null ? escapeHtml(String(result)) : '';
962
- } catch { return ''; }
963
- });
964
-
965
- for (let i = 0; i < list.length; i++) {
966
- const processed = evalReplace(tplOuter, list[i], i);
967
- const wrapper = document.createElement('div');
968
- wrapper.innerHTML = processed;
969
- while (wrapper.firstChild) fragment.appendChild(wrapper.firstChild);
970
- }
971
-
972
- parent.replaceChild(fragment, el);
973
- }
974
-
975
- // Handle remaining nested z-for (now exposed)
976
- if (root.querySelector('[z-for]')) _recurse(root);
977
- };
978
-
979
- _recurse(temp);
980
- return temp.innerHTML;
981
- }
982
-
983
- // ---------------------------------------------------------------------------
984
- // _expandContentDirectives - Pre-morph z-html & z-text expansion
985
- //
986
- // Evaluates z-html and z-text directives at the string level so the morph
987
- // engine receives HTML with the actual content inline. This lets the diff
988
- // algorithm properly compare old vs new content (text nodes, child elements)
989
- // instead of clearing + re-injecting on every re-render.
990
- //
991
- // Same parse → evaluate → serialize pattern as _expandZFor.
992
- // ---------------------------------------------------------------------------
993
- _expandContentDirectives(html) {
994
- if (!html.includes('z-html') && !html.includes('z-text')) return html;
995
-
996
- const temp = document.createElement('div');
997
- temp.innerHTML = html;
998
-
999
- // z-html: evaluate expression → inject as innerHTML
1000
- temp.querySelectorAll('[z-html]').forEach(el => {
1001
- if (el.closest('[z-pre]')) return;
1002
- const val = this._evalExpr(el.getAttribute('z-html'));
1003
- el.innerHTML = val != null ? String(val) : '';
1004
- el.removeAttribute('z-html');
1005
- });
1006
-
1007
- // z-text: evaluate expression inject as textContent (HTML-safe)
1008
- temp.querySelectorAll('[z-text]').forEach(el => {
1009
- if (el.closest('[z-pre]')) return;
1010
- const val = this._evalExpr(el.getAttribute('z-text'));
1011
- el.textContent = val != null ? String(val) : '';
1012
- el.removeAttribute('z-text');
1013
- });
1014
-
1015
- return temp.innerHTML;
1016
- }
1017
-
1018
- // ---------------------------------------------------------------------------
1019
- // _processDirectives - Post-innerHTML DOM-level directive processing
1020
- // ---------------------------------------------------------------------------
1021
- _processDirectives() {
1022
- // z-pre: skip all directive processing on subtrees
1023
- // (we leave z-pre elements in the DOM, but skip their descendants)
1024
-
1025
- // -- z-if / z-else-if / z-else (conditional rendering) --------
1026
- const ifEls = [...this._el.querySelectorAll('[z-if]')];
1027
- for (const el of ifEls) {
1028
- if (!el.parentNode || el.closest('[z-pre]')) continue;
1029
-
1030
- const show = !!this._evalExpr(el.getAttribute('z-if'));
1031
-
1032
- // Collect chain: adjacent z-else-if / z-else siblings
1033
- const chain = [{ el, show }];
1034
- let sib = el.nextElementSibling;
1035
- while (sib) {
1036
- if (sib.hasAttribute('z-else-if')) {
1037
- chain.push({ el: sib, show: !!this._evalExpr(sib.getAttribute('z-else-if')) });
1038
- sib = sib.nextElementSibling;
1039
- } else if (sib.hasAttribute('z-else')) {
1040
- chain.push({ el: sib, show: true });
1041
- break;
1042
- } else {
1043
- break;
1044
- }
1045
- }
1046
-
1047
- // Keep the first truthy branch, remove the rest
1048
- let found = false;
1049
- for (const item of chain) {
1050
- if (!found && item.show) {
1051
- found = true;
1052
- item.el.removeAttribute('z-if');
1053
- item.el.removeAttribute('z-else-if');
1054
- item.el.removeAttribute('z-else');
1055
- } else {
1056
- item.el.remove();
1057
- }
1058
- }
1059
- }
1060
-
1061
- // -- z-show (toggle display) -----------------------------------
1062
- this._el.querySelectorAll('[z-show]').forEach(el => {
1063
- if (el.closest('[z-pre]')) return;
1064
- const show = !!this._evalExpr(el.getAttribute('z-show'));
1065
- el.style.display = show ? '' : 'none';
1066
- el.removeAttribute('z-show');
1067
- });
1068
-
1069
- // -- z-bind:attr / :attr (dynamic attribute binding) -----------
1070
- // Use TreeWalker instead of querySelectorAll('*') - avoids
1071
- // creating a flat array of every single descendant element.
1072
- // TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
1073
- // at the walker level (faster than per-node closest('[z-pre]') checks).
1074
- const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
1075
- acceptNode(n) {
1076
- return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
1077
- }
1078
- });
1079
- let node;
1080
- while ((node = walker.nextNode())) {
1081
- const attrs = node.attributes;
1082
- for (let i = attrs.length - 1; i >= 0; i--) {
1083
- const attr = attrs[i];
1084
- let attrName;
1085
- if (attr.name.startsWith('z-bind:')) attrName = attr.name.slice(7);
1086
- else if (attr.name.charCodeAt(0) === 58 && attr.name.charCodeAt(1) !== 58) attrName = attr.name.slice(1); // ':' but not '::'
1087
- else continue;
1088
-
1089
- const val = this._evalExpr(attr.value);
1090
- node.removeAttribute(attr.name);
1091
- if (val === false || val === null || val === undefined) {
1092
- node.removeAttribute(attrName);
1093
- } else if (val === true) {
1094
- node.setAttribute(attrName, '');
1095
- } else {
1096
- node.setAttribute(attrName, String(val));
1097
- }
1098
- }
1099
- }
1100
-
1101
- // -- z-class (dynamic class binding) ---------------------------
1102
- this._el.querySelectorAll('[z-class]').forEach(el => {
1103
- if (el.closest('[z-pre]')) return;
1104
- const val = this._evalExpr(el.getAttribute('z-class'));
1105
- if (typeof val === 'string') {
1106
- val.split(/\s+/).filter(Boolean).forEach(c => el.classList.add(c));
1107
- } else if (Array.isArray(val)) {
1108
- val.filter(Boolean).forEach(c => el.classList.add(String(c)));
1109
- } else if (val && typeof val === 'object') {
1110
- for (const [cls, active] of Object.entries(val)) {
1111
- el.classList.toggle(cls, !!active);
1112
- }
1113
- }
1114
- el.removeAttribute('z-class');
1115
- });
1116
-
1117
- // -- z-style (dynamic inline styles) ---------------------------
1118
- this._el.querySelectorAll('[z-style]').forEach(el => {
1119
- if (el.closest('[z-pre]')) return;
1120
- const val = this._evalExpr(el.getAttribute('z-style'));
1121
- if (typeof val === 'string') {
1122
- el.style.cssText += ';' + val;
1123
- } else if (val && typeof val === 'object') {
1124
- for (const [prop, v] of Object.entries(val)) {
1125
- el.style[prop] = v;
1126
- }
1127
- }
1128
- el.removeAttribute('z-style');
1129
- });
1130
-
1131
- // z-html and z-text are now pre-expanded at string level (before
1132
- // morph) via _expandContentDirectives(), so the diff engine can
1133
- // properly diff their content instead of clearing + re-injecting.
1134
-
1135
- // -- z-cloak (remove after render) -----------------------------
1136
- this._el.querySelectorAll('[z-cloak]').forEach(el => {
1137
- el.removeAttribute('z-cloak');
1138
- });
1139
- }
1140
-
1141
- // Programmatic state update (batch-friendly)
1142
- // Passing an empty object forces a re-render (useful for external state changes).
1143
- setState(partial) {
1144
- if (partial && Object.keys(partial).length > 0) {
1145
- Object.assign(this.state, partial);
1146
- } else {
1147
- this._scheduleUpdate();
1148
- }
1149
- }
1150
-
1151
- // Emit custom event up the DOM
1152
- emit(name, detail) {
1153
- this._el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, cancelable: true }));
1154
- }
1155
-
1156
- // Destroy this component
1157
- destroy() {
1158
- if (this._destroyed) return;
1159
- this._destroyed = true;
1160
- if (this._def.destroyed) {
1161
- try { this._def.destroyed.call(this); }
1162
- catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" destroyed() threw`, { component: this._def._name }, err); }
1163
- }
1164
- this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
1165
- this._listeners = [];
1166
- if (this._outsideListeners) {
1167
- this._outsideListeners.forEach(({ event, handler }) => document.removeEventListener(event, handler, true));
1168
- this._outsideListeners = [];
1169
- }
1170
- this._delegatedEvents = null;
1171
- this._eventBindings = null;
1172
- // Clear any pending debounce/throttle timers to prevent stale closures.
1173
- // Timers are keyed by individual child elements, so iterate all descendants.
1174
- const allEls = this._el.querySelectorAll('*');
1175
- allEls.forEach(child => {
1176
- const dTimers = _debounceTimers.get(child);
1177
- if (dTimers) {
1178
- for (const key in dTimers) clearTimeout(dTimers[key]);
1179
- _debounceTimers.delete(child);
1180
- }
1181
- const tTimers = _throttleTimers.get(child);
1182
- if (tTimers) {
1183
- for (const key in tTimers) clearTimeout(tTimers[key]);
1184
- _throttleTimers.delete(child);
1185
- }
1186
- });
1187
- if (this._styleEl) this._styleEl.remove();
1188
- _instances.delete(this._el);
1189
- this._el.innerHTML = '';
1190
- }
1191
- }
1192
-
1193
-
1194
- // Reserved definition keys (not user methods)
1195
- const _reservedKeys = new Set([
1196
- 'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
1197
- 'templateUrl', 'styleUrl', 'templates', 'base',
1198
- 'computed', 'watch'
1199
- ]);
1200
-
1201
-
1202
- // ---------------------------------------------------------------------------
1203
- // Public API
1204
- // ---------------------------------------------------------------------------
1205
-
1206
- /**
1207
- * Register a component
1208
- * @param {string} name - tag name (must contain a hyphen, e.g. 'app-counter')
1209
- * @param {object} definition - component definition
1210
- */
1211
- export function component(name, definition) {
1212
- if (!name || typeof name !== 'string') {
1213
- throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, 'Component name must be a non-empty string');
1214
- }
1215
- if (!name.includes('-')) {
1216
- throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, `Component name "${name}" must contain a hyphen (Web Component convention)`);
1217
- }
1218
- definition._name = name;
1219
-
1220
- // Auto-detect the calling module's URL so that relative templateUrl
1221
- // and styleUrl paths resolve relative to the component file.
1222
- // An explicit `base` string on the definition overrides auto-detection.
1223
- if (definition.base !== undefined) {
1224
- definition._base = definition.base; // explicit override
1225
- } else {
1226
- definition._base = _detectCallerBase();
1227
- }
1228
-
1229
- _registry.set(name, definition);
1230
- }
1231
-
1232
- /**
1233
- * Mount a component into a target element
1234
- * @param {string|Element} target - selector or element to mount into
1235
- * @param {string} componentName - registered component name
1236
- * @param {object} props - props to pass
1237
- * @returns {Component}
1238
- */
1239
- export function mount(target, componentName, props = {}) {
1240
- const el = typeof target === 'string' ? document.querySelector(target) : target;
1241
- if (!el) throw new ZQueryError(ErrorCode.COMP_MOUNT_TARGET, `Mount target "${target}" not found`, { target });
1242
-
1243
- const def = _registry.get(componentName);
1244
- if (!def) throw new ZQueryError(ErrorCode.COMP_NOT_FOUND, `Component "${componentName}" not registered`, { component: componentName });
1245
-
1246
- // Destroy existing instance
1247
- if (_instances.has(el)) _instances.get(el).destroy();
1248
-
1249
- const instance = new Component(el, def, props);
1250
- _instances.set(el, instance);
1251
- instance._render();
1252
- return instance;
1253
- }
1254
-
1255
- /**
1256
- * Scan a container for custom component tags and auto-mount them
1257
- * @param {Element} root - root element to scan (default: document.body)
1258
- */
1259
- export function mountAll(root = document.body) {
1260
- for (const [name, def] of _registry) {
1261
- const tags = root.querySelectorAll(name);
1262
- tags.forEach(tag => {
1263
- if (_instances.has(tag)) return; // Already mounted
1264
-
1265
- // Extract props from attributes
1266
- const props = {};
1267
-
1268
- // Find parent component instance for evaluating dynamic prop expressions
1269
- let parentInstance = null;
1270
- let ancestor = tag.parentElement;
1271
- while (ancestor) {
1272
- if (_instances.has(ancestor)) {
1273
- parentInstance = _instances.get(ancestor);
1274
- break;
1275
- }
1276
- ancestor = ancestor.parentElement;
1277
- }
1278
-
1279
- [...tag.attributes].forEach(attr => {
1280
- if (attr.name.startsWith('@') || attr.name.startsWith('z-')) return;
1281
-
1282
- // Dynamic prop: :propName="expression" - evaluate in parent context
1283
- if (attr.name.startsWith(':')) {
1284
- const propName = attr.name.slice(1);
1285
- if (parentInstance) {
1286
- props[propName] = safeEval(attr.value, [
1287
- parentInstance.state.__raw || parentInstance.state,
1288
- { props: parentInstance.props, refs: parentInstance.refs, computed: parentInstance.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
1289
- ]);
1290
- } else {
1291
- // No parent - try JSON parse
1292
- try { props[propName] = JSON.parse(attr.value); }
1293
- catch { props[propName] = attr.value; }
1294
- }
1295
- return;
1296
- }
1297
-
1298
- // Static prop
1299
- try { props[attr.name] = JSON.parse(attr.value); }
1300
- catch { props[attr.name] = attr.value; }
1301
- });
1302
-
1303
- const instance = new Component(tag, def, props);
1304
- _instances.set(tag, instance);
1305
- instance._render();
1306
- });
1307
- }
1308
- }
1309
-
1310
- /**
1311
- * Get the component instance for an element
1312
- * @param {string|Element} target
1313
- * @returns {Component|null}
1314
- */
1315
- export function getInstance(target) {
1316
- const el = typeof target === 'string' ? document.querySelector(target) : target;
1317
- return _instances.get(el) || null;
1318
- }
1319
-
1320
- /**
1321
- * Destroy a component at the given target
1322
- * @param {string|Element} target
1323
- */
1324
- export function destroy(target) {
1325
- const el = typeof target === 'string' ? document.querySelector(target) : target;
1326
- const inst = _instances.get(el);
1327
- if (inst) inst.destroy();
1328
- }
1329
-
1330
- /**
1331
- * Get the registry (for debugging)
1332
- */
1333
- export function getRegistry() {
1334
- return Object.fromEntries(_registry);
1335
- }
1336
-
1337
- /**
1338
- * Pre-load a component's external templates and styles so the next mount
1339
- * renders synchronously (no blank flash while fetching).
1340
- * Safe to call multiple times - skips if already loaded.
1341
- * @param {string} name - registered component name
1342
- * @returns {Promise<void>}
1343
- */
1344
- export async function prefetch(name) {
1345
- const def = _registry.get(name);
1346
- if (!def) return;
1347
-
1348
- // Load templateUrl and styleUrl if not already loaded.
1349
- if ((def.templateUrl && !def._templateLoaded) ||
1350
- (def.styleUrl && !def._styleLoaded)) {
1351
- await Component.prototype._loadExternals.call({ _def: def });
1352
- }
1353
- }
1354
-
1355
-
1356
- // ---------------------------------------------------------------------------
1357
- // Global stylesheet loader
1358
- // ---------------------------------------------------------------------------
1359
- const _globalStyles = new Map(); // url → <link> element
1360
-
1361
- /**
1362
- * Load one or more global stylesheets into <head>.
1363
- * Relative URLs resolve against the calling module's directory (auto-detected
1364
- * from the stack trace), just like component styleUrl paths.
1365
- * Returns a remove() handle so the caller can unload if needed.
1366
- *
1367
- * $.style('app.css') // critical by default
1368
- * $.style(['app.css', 'theme.css']) // multiple files
1369
- * $.style('/assets/global.css') // absolute - used as-is
1370
- * $.style('app.css', { critical: false }) // opt out of FOUC prevention
1371
- *
1372
- * Options:
1373
- * critical - (boolean, default true) When true, zQuery injects a tiny
1374
- * inline style that hides the page (`visibility: hidden`) and
1375
- * removes it once the stylesheet has loaded. This prevents
1376
- * FOUC (Flash of Unstyled Content) entirely - no special
1377
- * markup needed in the HTML file. Set to false to load
1378
- * the stylesheet without blocking paint.
1379
- * bg - (string, default '#0d1117') Background color applied while
1380
- * the page is hidden during critical load. Prevents a white
1381
- * flash on dark-themed apps. Only used when critical is true.
1382
- *
1383
- * Duplicate URLs are ignored (idempotent).
1384
- *
1385
- * @param {string|string[]} urls - stylesheet URL(s) to load
1386
- * @param {object} [opts] - options
1387
- * @param {boolean} [opts.critical=true] - hide page until loaded (prevents FOUC)
1388
- * @param {string} [opts.bg] - background color while hidden (default '#0d1117')
1389
- * @returns {{ remove: Function, ready: Promise }} - .remove() to unload, .ready resolves when loaded
1390
- */
1391
- export function style(urls, opts = {}) {
1392
- const callerBase = _detectCallerBase();
1393
- const list = Array.isArray(urls) ? urls : [urls];
1394
- const elements = [];
1395
- const loadPromises = [];
1396
-
1397
- // Critical mode (default: true): inject a tiny inline <style> that hides the
1398
- // page and sets a background color. Fully self-contained - no markup needed
1399
- // in the HTML file. The style is removed once the sheet loads.
1400
- let _criticalStyle = null;
1401
- if (opts.critical !== false) {
1402
- _criticalStyle = document.createElement('style');
1403
- _criticalStyle.setAttribute('data-zq-critical', '');
1404
- _criticalStyle.textContent = `html{visibility:hidden!important;background:${opts.bg || '#0d1117'}}`;
1405
- document.head.insertBefore(_criticalStyle, document.head.firstChild);
1406
- }
1407
-
1408
- for (let url of list) {
1409
- // Resolve relative paths against the caller's directory first,
1410
- // falling back to <base href> or origin root.
1411
- if (typeof url === 'string' && !url.startsWith('/') && !url.includes(':') && !url.startsWith('//')) {
1412
- url = _resolveUrl(url, callerBase);
1413
- }
1414
-
1415
- if (_globalStyles.has(url)) {
1416
- elements.push(_globalStyles.get(url));
1417
- continue;
1418
- }
1419
-
1420
- const link = document.createElement('link');
1421
- link.rel = 'stylesheet';
1422
- link.href = url;
1423
- link.setAttribute('data-zq-style', '');
1424
-
1425
- const p = new Promise(resolve => {
1426
- link.onload = resolve;
1427
- link.onerror = resolve; // don't block forever on error
1428
- });
1429
- loadPromises.push(p);
1430
-
1431
- document.head.appendChild(link);
1432
- _globalStyles.set(url, link);
1433
- elements.push(link);
1434
- }
1435
-
1436
- // When all sheets are loaded, reveal the page if critical mode was used
1437
- const ready = Promise.all(loadPromises).then(() => {
1438
- if (_criticalStyle) {
1439
- _criticalStyle.remove();
1440
- }
1441
- });
1442
-
1443
- return {
1444
- ready,
1445
- remove() {
1446
- for (const el of elements) {
1447
- el.remove();
1448
- for (const [k, v] of _globalStyles) {
1449
- if (v === el) { _globalStyles.delete(k); break; }
1450
- }
1451
- }
1452
- }
1453
- };
1454
- }
1
+ /**
2
+ * zQuery Component - Lightweight reactive component system
3
+ *
4
+ * Declarative components using template literals with directive support.
5
+ * Proxy-based state triggers targeted re-renders via event delegation.
6
+ *
7
+ * Features:
8
+ * - Reactive state (auto re-render on mutation)
9
+ * - Template literals with full JS expression power
10
+ * - @event="method" syntax for event binding (delegated)
11
+ * - z-ref="name" for element references
12
+ * - z-model="stateKey" for two-way binding
13
+ * - Lifecycle hooks: init, mounted, updated, destroyed
14
+ * - Props passed via attributes
15
+ * - Scoped styles (inline or via styleUrl)
16
+ * - External templates via templateUrl (with {{expression}} interpolation)
17
+ * - External styles via styleUrl (fetched & scoped automatically)
18
+ * - Relative path resolution - templateUrl and styleUrl
19
+ * resolve relative to the component file automatically
20
+ */
21
+
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';
26
+ import { escapeHtml } from './utils.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Component registry & external resource cache
30
+ // ---------------------------------------------------------------------------
31
+ const _registry = new Map(); // name → definition
32
+ const _instances = new Map(); // element → instance
33
+ const _resourceCache = new Map(); // url → Promise<string>
34
+
35
+ // Unique ID counter
36
+ let _uid = 0;
37
+
38
+ // Inject z-cloak base style and mobile tap-highlight reset (once, globally)
39
+ if (typeof document !== 'undefined' && !document.querySelector('[data-zq-cloak]')) {
40
+ const _s = document.createElement('style');
41
+ _s.textContent = '[z-cloak]{display:none!important}*,*::before,*::after{-webkit-tap-highlight-color:transparent}';
42
+ _s.setAttribute('data-zq-cloak', '');
43
+ document.head.appendChild(_s);
44
+ }
45
+
46
+ // Debounce / throttle helpers for event modifiers
47
+ const _debounceTimers = new WeakMap();
48
+ const _throttleTimers = new WeakMap();
49
+
50
+ /**
51
+ * Fetch and cache a text resource (HTML template or CSS file).
52
+ * @param {string} url - URL to fetch
53
+ * @returns {Promise<string>}
54
+ */
55
+ function _fetchResource(url) {
56
+ if (_resourceCache.has(url)) return _resourceCache.get(url);
57
+
58
+ // Check inline resource map (populated by CLI bundler for file:// support).
59
+ // Keys are relative paths; match against the URL suffix.
60
+ if (typeof window !== 'undefined' && window.__zqInline) {
61
+ for (const [path, content] of Object.entries(window.__zqInline)) {
62
+ if (url === path || url.endsWith('/' + path) || url.endsWith('\\' + path)) {
63
+ const resolved = Promise.resolve(content);
64
+ _resourceCache.set(url, resolved);
65
+ return resolved;
66
+ }
67
+ }
68
+ }
69
+
70
+ // Resolve relative URLs against <base href> or origin root.
71
+ // This prevents SPA route paths (e.g. /docs/advanced) from
72
+ // breaking relative resource URLs like 'scripts/components/foo.css'.
73
+ let resolvedUrl = url;
74
+ if (typeof url === 'string' && !url.startsWith('/') && !url.includes(':') && !url.startsWith('//')) {
75
+ try {
76
+ const baseEl = document.querySelector('base');
77
+ const root = baseEl ? baseEl.href : (window.location.origin + '/');
78
+ resolvedUrl = new URL(url, root).href;
79
+ } catch { /* keep original */ }
80
+ }
81
+
82
+ const promise = fetch(resolvedUrl).then(res => {
83
+ if (!res.ok) throw new Error(`zQuery: Failed to load resource "${url}" (${res.status})`);
84
+ return res.text();
85
+ });
86
+ _resourceCache.set(url, promise);
87
+ return promise;
88
+ }
89
+
90
+ /**
91
+ * Resolve a relative URL against a base.
92
+ *
93
+ * - If `base` is an absolute URL (http/https/file), resolve directly.
94
+ * - If `base` is a relative path string, resolve it against the page root
95
+ * (or <base href>) first, then resolve `url` against that.
96
+ * - If `base` is falsy, return `url` unchanged - _fetchResource's own
97
+ * fallback (page root / <base href>) handles it.
98
+ *
99
+ * @param {string} url - URL or relative path to resolve
100
+ * @param {string} [base] - auto-detected caller URL or explicit base path
101
+ * @returns {string}
102
+ */
103
+ function _resolveUrl(url, base) {
104
+ if (!base || !url || typeof url !== 'string') return url;
105
+ // Already absolute - nothing to do
106
+ if (url.startsWith('/') || url.includes('://') || url.startsWith('//')) return url;
107
+ try {
108
+ if (base.includes('://')) {
109
+ // Absolute base (auto-detected module URL)
110
+ return new URL(url, base).href;
111
+ }
112
+ // Relative base string - resolve against page root first
113
+ const baseEl = document.querySelector('base');
114
+ const root = baseEl ? baseEl.href : (window.location.origin + '/');
115
+ const absBase = new URL(base.endsWith('/') ? base : base + '/', root).href;
116
+ return new URL(url, absBase).href;
117
+ } catch {
118
+ return url;
119
+ }
120
+ }
121
+
122
+ // Capture the library's own script URL at load time for reliable filtering.
123
+ // This handles cases where the bundle is renamed (e.g., 'vendor.js').
124
+ let _ownScriptUrl;
125
+ try {
126
+ if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
127
+ _ownScriptUrl = document.currentScript.src.replace(/[?#].*$/, '');
128
+ }
129
+ } catch { /* ignored */ }
130
+
131
+ /**
132
+ * Detect the URL of the module that called $.component().
133
+ * Parses Error().stack to find the first frame outside the zQuery bundle.
134
+ * Returns the directory URL (with trailing slash) or undefined.
135
+ * @returns {string|undefined}
136
+ */
137
+ function _detectCallerBase() {
138
+ try {
139
+ const stack = new Error().stack || '';
140
+ const urls = stack.match(/(?:https?|file):\/\/[^\s\)]+/g) || [];
141
+ for (const raw of urls) {
142
+ // Strip line:col suffixes e.g. ":3:5" or ":12:1"
143
+ const url = raw.replace(/:\d+:\d+$/, '').replace(/:\d+$/, '');
144
+ // Skip the zQuery library itself - by filename pattern and captured URL
145
+ if (/zquery(\.min)?\.js$/i.test(url)) continue;
146
+ if (_ownScriptUrl && url.replace(/[?#].*$/, '') === _ownScriptUrl) continue;
147
+ // Return directory (strip filename, keep trailing slash)
148
+ return url.replace(/\/[^/]*$/, '/');
149
+ }
150
+ } catch { /* stack parsing unsupported - fall back silently */ }
151
+ return undefined;
152
+ }
153
+
154
+ /**
155
+ * Get a value from a nested object by dot-path.
156
+ * _getPath(obj, 'user.name') → obj.user.name
157
+ * @param {object} obj
158
+ * @param {string} path
159
+ * @returns {*}
160
+ */
161
+ function _getPath(obj, path) {
162
+ return path.split('.').reduce((o, k) => o?.[k], obj);
163
+ }
164
+
165
+ /**
166
+ * Set a value on a nested object by dot-path, walking through proxy layers.
167
+ * _setPath(proxy, 'user.name', 'Tony') → proxy.user.name = 'Tony'
168
+ * @param {object} obj
169
+ * @param {string} path
170
+ * @param {*} value
171
+ */
172
+ function _setPath(obj, path, value) {
173
+ const keys = path.split('.');
174
+ const last = keys.pop();
175
+ const target = keys.reduce((o, k) => (o && typeof o === 'object') ? o[k] : undefined, obj);
176
+ if (target && typeof target === 'object') target[last] = value;
177
+ }
178
+
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Component class
182
+ // ---------------------------------------------------------------------------
183
+ class Component {
184
+ constructor(el, definition, props = {}) {
185
+ this._uid = ++_uid;
186
+ this._el = el;
187
+ this._def = definition;
188
+ this._mounted = false;
189
+ this._destroyed = false;
190
+ this._updateQueued = false;
191
+ this._listeners = [];
192
+ this._watchCleanups = [];
193
+
194
+ // Refs map
195
+ this.refs = {};
196
+
197
+ // Capture slot content before first render replaces it
198
+ this._slotContent = {};
199
+ const defaultSlotNodes = [];
200
+ [...el.childNodes].forEach(node => {
201
+ if (node.nodeType === 1 && node.hasAttribute('slot')) {
202
+ const slotName = node.getAttribute('slot') || 'default';
203
+ if (!this._slotContent[slotName]) this._slotContent[slotName] = '';
204
+ this._slotContent[slotName] += node.outerHTML;
205
+ } else if (node.nodeType === 1 || (node.nodeType === 3 && node.textContent.trim())) {
206
+ defaultSlotNodes.push(node.nodeType === 1 ? node.outerHTML : node.textContent);
207
+ }
208
+ });
209
+ if (defaultSlotNodes.length) {
210
+ this._slotContent['default'] = defaultSlotNodes.join('');
211
+ }
212
+
213
+ // Props - reactive when definition.props is defined, frozen otherwise
214
+ if (definition.props && typeof definition.props === 'object' && !Array.isArray(definition.props)) {
215
+ // Reactive props with type coercion and defaults
216
+ this.props = this._resolveReactiveProps(definition.props, props);
217
+ // MutationObserver to re-read props when parent re-renders and changes attributes
218
+ this._propObserver = new MutationObserver((mutations) => {
219
+ if (this._destroyed) return;
220
+ let changed = false;
221
+ for (const mut of mutations) {
222
+ if (mut.type === 'attributes') {
223
+ const attrName = mut.attributeName;
224
+ // Skip internal attributes
225
+ if (attrName.startsWith('z-') || attrName.startsWith('@') || attrName.startsWith(':') || attrName.startsWith('data-zq')) continue;
226
+ // Check if this is a defined prop (attribute names are lowercase)
227
+ const propName = attrName.startsWith(':') ? attrName.slice(1) : attrName;
228
+ if (propName in definition.props) {
229
+ changed = true;
230
+ }
231
+ }
232
+ }
233
+ if (changed) {
234
+ this.props = this._resolveReactiveProps(definition.props, {});
235
+ this._scheduleUpdate();
236
+ }
237
+ });
238
+ this._propObserver.observe(el, { attributes: true });
239
+ } else {
240
+ // Legacy: frozen props from parent
241
+ this.props = Object.freeze({ ...props });
242
+ }
243
+
244
+ // Store connectors - auto-subscribe to store keys
245
+ this._storeCleanups = [];
246
+ this.stores = {};
247
+ if (definition.stores && typeof definition.stores === 'object') {
248
+ for (const [alias, connector] of Object.entries(definition.stores)) {
249
+ if (!connector || !connector._zqConnector) continue;
250
+ const { store, keys } = connector;
251
+ // Initialize snapshot
252
+ const snap = {};
253
+ for (const key of keys) {
254
+ snap[key] = store.state[key];
255
+ }
256
+ this.stores[alias] = snap;
257
+ // Subscribe to changes
258
+ const unsub = store.subscribe(keys, (key, value) => {
259
+ this.stores[alias][key] = value;
260
+ if (!this._destroyed) this._scheduleUpdate();
261
+ });
262
+ this._storeCleanups.push(unsub);
263
+ }
264
+ }
265
+
266
+ // Reactive state
267
+ const initialState = typeof definition.state === 'function'
268
+ ? definition.state()
269
+ : { ...(definition.state || {}) };
270
+
271
+ this.state = reactive(initialState, (key, value, old) => {
272
+ if (!this._destroyed) {
273
+ // Run watchers for the changed key
274
+ this._runWatchers(key, value, old);
275
+ this._scheduleUpdate();
276
+ }
277
+ });
278
+
279
+ // Computed properties - lazy getters derived from state
280
+ this.computed = {};
281
+ if (definition.computed) {
282
+ for (const [name, fn] of Object.entries(definition.computed)) {
283
+ Object.defineProperty(this.computed, name, {
284
+ get: () => fn.call(this, this.state.__raw || this.state),
285
+ enumerable: true
286
+ });
287
+ }
288
+ }
289
+
290
+ // Bind all user methods to this instance
291
+ for (const [key, val] of Object.entries(definition)) {
292
+ if (typeof val === 'function' && !_reservedKeys.has(key)) {
293
+ this[key] = val.bind(this);
294
+ }
295
+ }
296
+
297
+ // Init lifecycle
298
+ if (definition.init) {
299
+ try { definition.init.call(this); }
300
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${definition._name}" init() threw`, { component: definition._name }, err); }
301
+ }
302
+
303
+ // Set up watchers after init so initial state is ready
304
+ if (definition.watch) {
305
+ this._prevWatchValues = {};
306
+ for (const key of Object.keys(definition.watch)) {
307
+ this._prevWatchValues[key] = _getPath(this.state.__raw || this.state, key);
308
+ }
309
+ }
310
+ }
311
+
312
+ // Run registered watchers for a changed key
313
+ _runWatchers(changedKey, value, old) {
314
+ const watchers = this._def.watch;
315
+ if (!watchers) return;
316
+ for (const [key, handler] of Object.entries(watchers)) {
317
+ // Match exact key or parent key (e.g. watcher on 'user' fires when 'user.name' changes)
318
+ if (changedKey === key || key.startsWith(changedKey + '.') || changedKey.startsWith(key + '.')) {
319
+ const currentVal = _getPath(this.state.__raw || this.state, key);
320
+ const prevVal = this._prevWatchValues?.[key];
321
+ if (currentVal !== prevVal) {
322
+ const fn = typeof handler === 'function' ? handler : handler.handler;
323
+ if (typeof fn === 'function') fn.call(this, currentVal, prevVal);
324
+ if (this._prevWatchValues) this._prevWatchValues[key] = currentVal;
325
+ }
326
+ }
327
+ }
328
+ }
329
+
330
+ // Schedule a batched DOM update (microtask)
331
+ _scheduleUpdate() {
332
+ if (this._updateQueued) return;
333
+ this._updateQueued = true;
334
+ queueMicrotask(() => {
335
+ try {
336
+ if (!this._destroyed) this._render();
337
+ } finally {
338
+ this._updateQueued = false;
339
+ }
340
+ });
341
+ }
342
+
343
+ /**
344
+ * Resolve reactive props from the definition's prop schema.
345
+ * Reads from element attributes, applies type coercion and defaults.
346
+ * Passed props (from mount) override attributes.
347
+ * @param {object} propDefs - { propName: { type, default } }
348
+ * @param {object} passedProps - props passed programmatically from mount()
349
+ * @returns {object} resolved props (frozen)
350
+ */
351
+ _resolveReactiveProps(propDefs, passedProps) {
352
+ const resolved = {};
353
+ for (const [name, schema] of Object.entries(propDefs)) {
354
+ const def = typeof schema === 'object' && schema !== null ? schema : { type: schema };
355
+ const type = def.type;
356
+ const defaultVal = def.default;
357
+
358
+ // Priority: passed props > dynamic :prop attribute > static attribute > default
359
+ if (name in passedProps) {
360
+ resolved[name] = passedProps[name];
361
+ continue;
362
+ }
363
+
364
+ // Check for dynamic :prop attribute (already evaluated by parent mount)
365
+ let rawAttr = this._el.getAttribute(':' + name);
366
+ let hasAttr = rawAttr !== null;
367
+ if (!hasAttr) {
368
+ rawAttr = this._el.getAttribute(name);
369
+ hasAttr = rawAttr !== null;
370
+ }
371
+
372
+ if (hasAttr && rawAttr !== null) {
373
+ resolved[name] = this._coercePropValue(rawAttr, type);
374
+ } else if (defaultVal !== undefined) {
375
+ resolved[name] = typeof defaultVal === 'function' ? defaultVal() : defaultVal;
376
+ } else {
377
+ resolved[name] = undefined;
378
+ }
379
+ }
380
+ return Object.freeze(resolved);
381
+ }
382
+
383
+ /**
384
+ * Coerce a raw attribute string to the specified type.
385
+ * @param {string} raw - attribute value string
386
+ * @param {Function} type - String, Number, Boolean, Object, or Array
387
+ * @returns {*}
388
+ */
389
+ _coercePropValue(raw, type) {
390
+ if (type === Number) return Number(raw);
391
+ if (type === Boolean) return raw !== 'false' && raw !== '0' && raw !== '';
392
+ if (type === Object || type === Array) {
393
+ try { return JSON.parse(raw); } catch { return raw; }
394
+ }
395
+ return raw; // String or unspecified
396
+ }
397
+
398
+ // Load external templateUrl / styleUrl if specified (once per definition)
399
+ //
400
+ // Relative paths are resolved automatically against the component file's
401
+ // own directory (auto-detected at registration time). You can override
402
+ // this with `base: 'some/path/'` on the definition.
403
+ //
404
+ // templateUrl accepts:
405
+ // - string → single template (used with {{expr}} interpolation)
406
+ // - string[] → array of URLs → indexed map via this.templates[0], …
407
+ // - { key: url, } → named map → this.templates.key
408
+ //
409
+ // styleUrl accepts:
410
+ // - string → single stylesheet
411
+ // - string[] → array of URLs → all fetched & concatenated
412
+ //
413
+ async _loadExternals() {
414
+ const def = this._def;
415
+ const base = def._base; // auto-detected or explicit
416
+
417
+ // -- External templates --------------------------------------
418
+ if (def.templateUrl && !def._templateLoaded) {
419
+ const tu = def.templateUrl;
420
+ if (typeof tu === 'string') {
421
+ def._externalTemplate = await _fetchResource(_resolveUrl(tu, base));
422
+ } else if (Array.isArray(tu)) {
423
+ const urls = tu.map(u => _resolveUrl(u, base));
424
+ const results = await Promise.all(urls.map(u => _fetchResource(u)));
425
+ def._externalTemplates = {};
426
+ results.forEach((html, i) => { def._externalTemplates[i] = html; });
427
+ } else if (typeof tu === 'object') {
428
+ const entries = Object.entries(tu);
429
+ const results = await Promise.all(
430
+ entries.map(([, url]) => _fetchResource(_resolveUrl(url, base)))
431
+ );
432
+ def._externalTemplates = {};
433
+ entries.forEach(([key], i) => { def._externalTemplates[key] = results[i]; });
434
+ }
435
+ def._templateLoaded = true;
436
+ }
437
+
438
+ // -- External styles -----------------------------------------
439
+ if (def.styleUrl && !def._styleLoaded) {
440
+ const su = def.styleUrl;
441
+ if (typeof su === 'string') {
442
+ const resolved = _resolveUrl(su, base);
443
+ def._externalStyles = await _fetchResource(resolved);
444
+ def._resolvedStyleUrls = [resolved];
445
+ } else if (Array.isArray(su)) {
446
+ const urls = su.map(u => _resolveUrl(u, base));
447
+ const results = await Promise.all(urls.map(u => _fetchResource(u)));
448
+ def._externalStyles = results.join('\n');
449
+ def._resolvedStyleUrls = urls;
450
+ }
451
+ def._styleLoaded = true;
452
+ }
453
+ }
454
+
455
+ // Render the component
456
+ _render() {
457
+ // If externals haven't loaded yet, trigger async load then re-render
458
+ if ((this._def.templateUrl && !this._def._templateLoaded) ||
459
+ (this._def.styleUrl && !this._def._styleLoaded)) {
460
+ this._loadExternals().then(() => {
461
+ if (!this._destroyed) this._render();
462
+ });
463
+ return; // Skip this render - will re-render after load
464
+ }
465
+
466
+ // Expose multi-template map on instance (if available)
467
+ if (this._def._externalTemplates) {
468
+ this.templates = this._def._externalTemplates;
469
+ }
470
+
471
+ // Determine HTML content
472
+ let html;
473
+ if (this._def.render) {
474
+ // Inline render function takes priority
475
+ html = this._def.render.call(this);
476
+ // Expand z-for in render templates ({{}} expressions for iteration items)
477
+ html = this._expandZFor(html);
478
+ } else if (this._def._externalTemplate) {
479
+ // Expand z-for FIRST (before global {{}} interpolation)
480
+ html = this._expandZFor(this._def._externalTemplate);
481
+ // Then do global {{expression}} interpolation on the remaining content
482
+ html = html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
483
+ try {
484
+ const result = safeEval(expr.trim(), [
485
+ this.state.__raw || this.state,
486
+ { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
487
+ ]);
488
+ return result != null ? escapeHtml(String(result)) : '';
489
+ } catch { return ''; }
490
+ });
491
+ } else {
492
+ html = '';
493
+ }
494
+
495
+ // Pre-expand z-html and z-text at string level so the morph engine
496
+ // can diff their content properly (instead of clearing + re-injecting
497
+ // on every re-render). Same pattern as z-for: parse → evaluate → serialize.
498
+ html = this._expandContentDirectives(html);
499
+
500
+ // -- Slot distribution ----------------------------------------
501
+ // Replace <slot> elements with captured slot content from parent.
502
+ // <slot> → default slot content
503
+ // <slot name="header"> → named slot content
504
+ // Fallback content between <slot>...</slot> used when no content provided.
505
+ if (html.includes('<slot')) {
506
+ html = html.replace(/<slot(?:\s+name="([^"]*)")?\s*(?:\/>|>([\s\S]*?)<\/slot>)/g, (_, name, fallback) => {
507
+ const slotName = name || 'default';
508
+ return this._slotContent[slotName] || fallback || '';
509
+ });
510
+ }
511
+
512
+ // Combine inline styles + external styles
513
+ const combinedStyles = [
514
+ this._def.styles || '',
515
+ this._def._externalStyles || ''
516
+ ].filter(Boolean).join('\n');
517
+
518
+ // Apply scoped styles on first render
519
+ if (!this._mounted && combinedStyles) {
520
+ const scopeAttr = `z-s${this._uid}`;
521
+ this._el.setAttribute(scopeAttr, '');
522
+ let noScopeDepth = 0; // brace depth at which a no-scope @-rule started (0 = none active)
523
+ let braceDepth = 0; // overall brace depth
524
+ const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
525
+ if (match === '}') {
526
+ if (noScopeDepth > 0 && braceDepth <= noScopeDepth) noScopeDepth = 0;
527
+ braceDepth--;
528
+ return match;
529
+ }
530
+ braceDepth++;
531
+ const trimmed = selector.trim();
532
+ // Don't scope @-rules themselves
533
+ if (trimmed.startsWith('@')) {
534
+ // @keyframes and @font-face contain non-selector content - skip scoping inside them
535
+ if (/^@(keyframes|font-face)\b/.test(trimmed)) {
536
+ noScopeDepth = braceDepth;
537
+ }
538
+ return match;
539
+ }
540
+ // Inside @keyframes or @font-face - don't scope inner rules
541
+ if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
542
+ return match;
543
+ }
544
+ return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
545
+ });
546
+ const styleEl = document.createElement('style');
547
+ styleEl.textContent = scoped;
548
+ styleEl.setAttribute('data-zq-component', this._def._name || '');
549
+ styleEl.setAttribute('data-zq-scope', scopeAttr);
550
+ if (this._def._resolvedStyleUrls) {
551
+ styleEl.setAttribute('data-zq-style-urls', this._def._resolvedStyleUrls.join(' '));
552
+ if (this._def.styles) {
553
+ styleEl.setAttribute('data-zq-inline', this._def.styles);
554
+ }
555
+ }
556
+ document.head.appendChild(styleEl);
557
+ this._styleEl = styleEl;
558
+ }
559
+
560
+ // -- Focus preservation ----------------------------------------
561
+ // DOM morphing preserves unchanged nodes naturally, but we still
562
+ // track focus for cases where the focused element's subtree changes.
563
+ let _focusInfo = null;
564
+ const _active = document.activeElement;
565
+ if (_active && this._el.contains(_active)) {
566
+ const modelKey = _active.getAttribute?.('z-model');
567
+ const refKey = _active.getAttribute?.('z-ref');
568
+ let selector = null;
569
+ if (modelKey) {
570
+ selector = `[z-model="${modelKey}"]`;
571
+ } else if (refKey) {
572
+ selector = `[z-ref="${refKey}"]`;
573
+ } else {
574
+ const tag = _active.tagName.toLowerCase();
575
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
576
+ let s = tag;
577
+ if (_active.type) s += `[type="${_active.type}"]`;
578
+ if (_active.name) s += `[name="${_active.name}"]`;
579
+ if (_active.placeholder) s += `[placeholder="${CSS.escape(_active.placeholder)}"]`;
580
+ selector = s;
581
+ }
582
+ }
583
+ if (selector) {
584
+ _focusInfo = {
585
+ selector,
586
+ start: _active.selectionStart,
587
+ end: _active.selectionEnd,
588
+ dir: _active.selectionDirection,
589
+ };
590
+ }
591
+ }
592
+
593
+ // Update DOM via morphing (diffing) - preserves unchanged nodes
594
+ // First render uses innerHTML for speed; subsequent renders morph.
595
+ const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
596
+ if (!this._mounted) {
597
+ this._el.innerHTML = html;
598
+ if (_renderStart && window.__zqRenderHook) window.__zqRenderHook(this._el, performance.now() - _renderStart, 'mount', this._def._name);
599
+ } else {
600
+ morph(this._el, html);
601
+ }
602
+
603
+ // Process structural & attribute directives
604
+ this._processDirectives();
605
+
606
+ // Process event, ref, and model bindings
607
+ this._bindEvents();
608
+ this._bindRefs();
609
+ this._bindModels();
610
+
611
+ // Restore focus if the morph replaced the focused element.
612
+ // Always restore selectionRange - even when the element is still
613
+ // the activeElement - because _bindModels or morph attribute syncing
614
+ // can alter the value and move the cursor.
615
+ if (_focusInfo) {
616
+ const el = this._el.querySelector(_focusInfo.selector);
617
+ if (el) {
618
+ if (el !== document.activeElement) el.focus();
619
+ try {
620
+ if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
621
+ el.setSelectionRange(_focusInfo.start, _focusInfo.end, _focusInfo.dir);
622
+ }
623
+ } catch (_) { /* some input types don't support setSelectionRange */ }
624
+ }
625
+ }
626
+
627
+ // Mount nested components
628
+ mountAll(this._el);
629
+
630
+ if (!this._mounted) {
631
+ this._mounted = true;
632
+ if (this._def.mounted) {
633
+ try { this._def.mounted.call(this); }
634
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" mounted() threw`, { component: this._def._name }, err); }
635
+ }
636
+ } else {
637
+ if (this._def.updated) {
638
+ try { this._def.updated.call(this); }
639
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" updated() threw`, { component: this._def._name }, err); }
640
+ }
641
+ }
642
+ }
643
+
644
+ // Bind @event="method" and z-on:event="method" handlers via delegation.
645
+ //
646
+ // Optimization: on the FIRST render, we scan for event attributes, build
647
+ // a delegated handler map, and attach one listener per event type to the
648
+ // component root. On subsequent renders (re-bind), we only rebuild the
649
+ // internal binding map - existing DOM listeners are reused since they
650
+ // delegate to event.target.closest(selector) at fire time.
651
+ _bindEvents() {
652
+ // Always rebuild the binding map from current DOM
653
+ const eventMap = new Map(); // event → [{ selector, methodExpr, modifiers, el }]
654
+
655
+ const allEls = this._el.querySelectorAll('*');
656
+ allEls.forEach(child => {
657
+ if (child.closest('[z-pre]')) return;
658
+
659
+ const attrs = child.attributes;
660
+ for (let a = 0; a < attrs.length; a++) {
661
+ const attr = attrs[a];
662
+ let raw;
663
+ if (attr.name.charCodeAt(0) === 64) { // '@'
664
+ raw = attr.name.slice(1);
665
+ } else if (attr.name.startsWith('z-on:')) {
666
+ raw = attr.name.slice(5);
667
+ } else {
668
+ continue;
669
+ }
670
+
671
+ const parts = raw.split('.');
672
+ const event = parts[0];
673
+ const modifiers = parts.slice(1);
674
+ const methodExpr = attr.value;
675
+
676
+ // Give element a unique selector for delegation
677
+ if (!child.dataset.zqEid) {
678
+ child.dataset.zqEid = String(++_uid);
679
+ }
680
+ const selector = `[data-zq-eid="${child.dataset.zqEid}"]`;
681
+
682
+ if (!eventMap.has(event)) eventMap.set(event, []);
683
+ eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
684
+ }
685
+ });
686
+
687
+ // Store binding map for the delegated handlers to reference
688
+ this._eventBindings = eventMap;
689
+
690
+ // Only attach DOM listeners once - reuse on subsequent renders.
691
+ // The handlers close over `this` and read `this._eventBindings`
692
+ // at fire time, so they always use the latest binding map.
693
+ if (this._delegatedEvents) {
694
+ // Already attached - just make sure new event types are covered
695
+ for (const event of eventMap.keys()) {
696
+ if (!this._delegatedEvents.has(event)) {
697
+ this._attachDelegatedEvent(event, eventMap.get(event));
698
+ }
699
+ }
700
+ // Remove listeners for event types no longer in the template
701
+ for (const event of this._delegatedEvents.keys()) {
702
+ if (!eventMap.has(event)) {
703
+ const { handler, opts } = this._delegatedEvents.get(event);
704
+ this._el.removeEventListener(event, handler, opts);
705
+ this._delegatedEvents.delete(event);
706
+ // Also remove from _listeners array
707
+ this._listeners = this._listeners.filter(l => l.event !== event);
708
+ }
709
+ }
710
+ return;
711
+ }
712
+
713
+ this._delegatedEvents = new Map();
714
+
715
+ // Register delegated listeners on the component root
716
+ for (const [event, bindings] of eventMap) {
717
+ this._attachDelegatedEvent(event, bindings);
718
+ }
719
+
720
+ // .outside - attach a document-level listener for bindings that need
721
+ // to detect clicks/events outside their element.
722
+ this._outsideListeners = this._outsideListeners || [];
723
+ for (const [event, bindings] of eventMap) {
724
+ for (const binding of bindings) {
725
+ if (!binding.modifiers.includes('outside')) continue;
726
+ const outsideHandler = (e) => {
727
+ if (binding.el.contains(e.target)) return;
728
+ const match = binding.methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
729
+ if (!match) return;
730
+ const fn = this[match[1]];
731
+ if (typeof fn === 'function') fn.call(this, e);
732
+ };
733
+ document.addEventListener(event, outsideHandler, true);
734
+ this._outsideListeners.push({ event, handler: outsideHandler });
735
+ }
736
+ }
737
+ }
738
+
739
+ // Attach a single delegated listener for an event type
740
+ _attachDelegatedEvent(event, bindings) {
741
+ const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
742
+ const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
743
+ const listenerOpts = (needsCapture || needsPassive)
744
+ ? { capture: needsCapture, passive: needsPassive }
745
+ : false;
746
+
747
+ const handler = (e) => {
748
+ // Read bindings from live map - always up to date after re-renders
749
+ const currentBindings = this._eventBindings?.get(event) || [];
750
+
751
+ // Collect matching bindings with their matched elements, then sort
752
+ // deepest-first so .stop correctly prevents ancestor handlers
753
+ // (mimics real DOM bubbling order within delegated events).
754
+ const hits = [];
755
+ for (const binding of currentBindings) {
756
+ const matched = e.target.closest(binding.selector);
757
+ if (!matched) continue;
758
+ hits.push({ ...binding, matched });
759
+ }
760
+ hits.sort((a, b) => {
761
+ if (a.matched === b.matched) return 0;
762
+ return a.matched.contains(b.matched) ? 1 : -1;
763
+ });
764
+
765
+ let stoppedAt = null; // Track elements that called .stop
766
+ for (const { selector, methodExpr, modifiers, el, matched } of hits) {
767
+
768
+ // In delegated events, .stop should prevent ancestor bindings from
769
+ // firing - stopPropagation alone only stops real DOM bubbling.
770
+ if (stoppedAt) {
771
+ let blocked = false;
772
+ for (const stopped of stoppedAt) {
773
+ if (matched.contains(stopped) && matched !== stopped) { blocked = true; break; }
774
+ }
775
+ if (blocked) continue;
776
+ }
777
+
778
+ // .self - only fire if target is the element itself
779
+ if (modifiers.includes('self') && e.target !== el) continue;
780
+
781
+ // .outside - only fire if event target is OUTSIDE the element
782
+ if (modifiers.includes('outside')) {
783
+ if (el.contains(e.target)) continue;
784
+ }
785
+
786
+ // Key modifiers - filter keyboard events by key.
787
+ // Named shortcuts map common names to their e.key values.
788
+ // Any modifier not recognised as a built-in behaviour, timing,
789
+ // or system modifier is matched against e.key (case-insensitive)
790
+ // so that arbitrary keys work: .a, .f1, .+, .0, .arrowup, etc.
791
+ const _keyMap = { enter: 'Enter', escape: 'Escape', tab: 'Tab', space: ' ', delete: 'Delete|Backspace', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
792
+ const _nonKeyMods = new Set(['prevent','stop','self','once','outside','capture','passive','debounce','throttle','ctrl','shift','alt','meta']);
793
+ let keyFiltered = false;
794
+ for (let mi = 0; mi < modifiers.length; mi++) {
795
+ const mod = modifiers[mi];
796
+ if (_keyMap[mod]) {
797
+ const keys = _keyMap[mod].split('|');
798
+ if (!e.key || !keys.includes(e.key)) { keyFiltered = true; break; }
799
+ } else if (_nonKeyMods.has(mod)) {
800
+ continue;
801
+ } else if (/^\d+$/.test(mod) && mi > 0 && (modifiers[mi - 1] === 'debounce' || modifiers[mi - 1] === 'throttle')) {
802
+ // Numeric value following debounce/throttle skip (it's a ms parameter)
803
+ continue;
804
+ } else {
805
+ // Dynamic key match compare modifier against e.key
806
+ // Case-insensitive: .a matches 'a' and 'A', .f1 matches 'F1'
807
+ if (!e.key || e.key.toLowerCase() !== mod.toLowerCase()) { keyFiltered = true; break; }
808
+ }
809
+ }
810
+ if (keyFiltered) continue;
811
+
812
+ // System key modifiers - require modifier keys to be held
813
+ if (modifiers.includes('ctrl') && !e.ctrlKey) continue;
814
+ if (modifiers.includes('shift') && !e.shiftKey) continue;
815
+ if (modifiers.includes('alt') && !e.altKey) continue;
816
+ if (modifiers.includes('meta') && !e.metaKey) continue;
817
+
818
+ // Handle modifiers
819
+ if (modifiers.includes('prevent')) e.preventDefault();
820
+ if (modifiers.includes('stop')) {
821
+ e.stopPropagation();
822
+ if (!stoppedAt) stoppedAt = [];
823
+ stoppedAt.push(matched);
824
+ }
825
+
826
+ // Build the invocation function
827
+ const invoke = (evt) => {
828
+ const match = methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
829
+ if (!match) return;
830
+ const methodName = match[1];
831
+ const fn = this[methodName];
832
+ if (typeof fn !== 'function') return;
833
+ if (match[2] !== undefined) {
834
+ const args = match[2].split(',').map(a => {
835
+ a = a.trim();
836
+ if (a === '') return undefined;
837
+ if (a === '$event') return evt;
838
+ if (a === 'true') return true;
839
+ if (a === 'false') return false;
840
+ if (a === 'null') return null;
841
+ if (/^-?\d+(\.\d+)?$/.test(a)) return Number(a);
842
+ if ((a.startsWith("'") && a.endsWith("'")) || (a.startsWith('"') && a.endsWith('"'))) return a.slice(1, -1);
843
+ if (a.startsWith('state.')) return _getPath(this.state, a.slice(6));
844
+ return a;
845
+ }).filter(a => a !== undefined);
846
+ fn(...args);
847
+ } else {
848
+ fn(evt);
849
+ }
850
+ };
851
+
852
+ // .debounce.{ms} - delay invocation until idle
853
+ const debounceIdx = modifiers.indexOf('debounce');
854
+ if (debounceIdx !== -1) {
855
+ const ms = parseInt(modifiers[debounceIdx + 1], 10) || 250;
856
+ const timers = _debounceTimers.get(el) || {};
857
+ clearTimeout(timers[event]);
858
+ timers[event] = setTimeout(() => invoke(e), ms);
859
+ _debounceTimers.set(el, timers);
860
+ continue;
861
+ }
862
+
863
+ // .throttle.{ms} - fire at most once per interval
864
+ const throttleIdx = modifiers.indexOf('throttle');
865
+ if (throttleIdx !== -1) {
866
+ const ms = parseInt(modifiers[throttleIdx + 1], 10) || 250;
867
+ const timers = _throttleTimers.get(el) || {};
868
+ if (timers[event]) continue;
869
+ invoke(e);
870
+ timers[event] = setTimeout(() => { timers[event] = null; }, ms);
871
+ _throttleTimers.set(el, timers);
872
+ continue;
873
+ }
874
+
875
+ // .once - fire once then ignore
876
+ if (modifiers.includes('once')) {
877
+ if (el.dataset.zqOnce === event) continue;
878
+ el.dataset.zqOnce = event;
879
+ }
880
+
881
+ invoke(e);
882
+ }
883
+ };
884
+ this._el.addEventListener(event, handler, listenerOpts);
885
+ this._listeners.push({ event, handler });
886
+ this._delegatedEvents.set(event, { handler, opts: listenerOpts });
887
+ }
888
+
889
+ // Bind z-ref="name" → this.refs.name
890
+ _bindRefs() {
891
+ this.refs = {};
892
+ this._el.querySelectorAll('[z-ref]').forEach(el => {
893
+ this.refs[el.getAttribute('z-ref')] = el;
894
+ });
895
+ }
896
+
897
+ // Bind z-model="stateKey" for two-way binding
898
+ //
899
+ // Supported elements: input (text, number, range, checkbox, radio, date, color, …),
900
+ // textarea, select (single & multiple), contenteditable
901
+ // Nested state keys: z-model="user.name" → this.state.user.name
902
+ // Modifiers (boolean attributes on the same element):
903
+ // z-lazy - listen on 'change' instead of 'input' (update on blur / commit)
904
+ // z-trim - trim whitespace before writing to state
905
+ // z-number - force Number() conversion regardless of input type
906
+ // z-debounce - debounce state writes (default 250ms, or z-debounce="300")
907
+ // z-uppercase - convert string to uppercase before writing to state
908
+ // z-lowercase - convert string to lowercase before writing to state
909
+ //
910
+ // Writes to reactive state so the rest of the UI stays in sync.
911
+ // Focus and cursor position are preserved in _render() via focusInfo.
912
+ //
913
+ _bindModels() {
914
+ this._el.querySelectorAll('[z-model]').forEach(el => {
915
+ const key = el.getAttribute('z-model');
916
+ const tag = el.tagName.toLowerCase();
917
+ const type = (el.type || '').toLowerCase();
918
+ const isEditable = el.hasAttribute('contenteditable');
919
+
920
+ // Modifiers
921
+ const isLazy = el.hasAttribute('z-lazy');
922
+ const isTrim = el.hasAttribute('z-trim');
923
+ const isNum = el.hasAttribute('z-number');
924
+ const isUpper = el.hasAttribute('z-uppercase');
925
+ const isLower = el.hasAttribute('z-lowercase');
926
+ const hasDebounce = el.hasAttribute('z-debounce');
927
+ const debounceMs = hasDebounce ? (parseInt(el.getAttribute('z-debounce'), 10) || 250) : 0;
928
+
929
+ // Read current state value (supports dot-path keys)
930
+ const currentVal = _getPath(this.state, key);
931
+
932
+ // -- Set initial DOM value from state (always sync) ----------
933
+ if (tag === 'input' && type === 'checkbox') {
934
+ el.checked = !!currentVal;
935
+ } else if (tag === 'input' && type === 'radio') {
936
+ el.checked = el.value === String(currentVal);
937
+ } else if (tag === 'select' && el.multiple) {
938
+ const vals = Array.isArray(currentVal) ? currentVal.map(String) : [];
939
+ [...el.options].forEach(opt => { opt.selected = vals.includes(opt.value); });
940
+ } else if (isEditable) {
941
+ if (el.textContent !== String(currentVal ?? '')) {
942
+ el.textContent = currentVal ?? '';
943
+ }
944
+ } else {
945
+ el.value = currentVal ?? '';
946
+ }
947
+
948
+ // -- Determine event type ------------------------------------
949
+ const event = isLazy || tag === 'select' || type === 'checkbox' || type === 'radio'
950
+ ? 'change'
951
+ : isEditable ? 'input' : 'input';
952
+
953
+ // -- Handler: read DOM → write to reactive state -------------
954
+ // Skip if already bound (morph preserves existing elements,
955
+ // so re-binding would stack duplicate listeners)
956
+ if (el._zqModelBound) return;
957
+ el._zqModelBound = true;
958
+
959
+ const handler = () => {
960
+ let val;
961
+ if (type === 'checkbox') val = el.checked;
962
+ else if (tag === 'select' && el.multiple) val = [...el.selectedOptions].map(o => o.value);
963
+ else if (isEditable) val = el.textContent;
964
+ else val = el.value;
965
+
966
+ // Apply modifiers
967
+ if (isTrim && typeof val === 'string') val = val.trim();
968
+ if (isUpper && typeof val === 'string') val = val.toUpperCase();
969
+ if (isLower && typeof val === 'string') val = val.toLowerCase();
970
+ if (isNum || type === 'number' || type === 'range') val = Number(val);
971
+
972
+ // Write through the reactive proxy (triggers re-render).
973
+ // Focus + cursor are preserved automatically by _render().
974
+ _setPath(this.state, key, val);
975
+ };
976
+
977
+ if (hasDebounce) {
978
+ let timer = null;
979
+ el.addEventListener(event, () => {
980
+ clearTimeout(timer);
981
+ timer = setTimeout(handler, debounceMs);
982
+ });
983
+ } else {
984
+ el.addEventListener(event, handler);
985
+ }
986
+ });
987
+ }
988
+
989
+ // ---------------------------------------------------------------------------
990
+ // Expression evaluator - CSP-safe parser (no eval / new Function)
991
+ // ---------------------------------------------------------------------------
992
+ _evalExpr(expr) {
993
+ return safeEval(expr, [
994
+ this.state.__raw || this.state,
995
+ { props: this.props, refs: this.refs, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
996
+ ]);
997
+ }
998
+
999
+ // ---------------------------------------------------------------------------
1000
+ // z-for - Expand list-rendering directives (pre-innerHTML, string level)
1001
+ //
1002
+ // <li z-for="item in items">{{item.name}}</li>
1003
+ // <li z-for="(item, i) in items">{{i}}: {{item.name}}</li>
1004
+ // <div z-for="n in 5">{{n}}</div> (range)
1005
+ // <div z-for="(val, key) in obj">{{key}}: {{val}}</div> (object)
1006
+ //
1007
+ // Uses a temporary DOM to parse, clone elements per item, and evaluate
1008
+ // {{}} expressions with the iteration variable in scope.
1009
+ // ---------------------------------------------------------------------------
1010
+ _expandZFor(html) {
1011
+ if (!html.includes('z-for')) return html;
1012
+
1013
+ const temp = document.createElement('div');
1014
+ temp.innerHTML = html;
1015
+
1016
+ const _recurse = (root) => {
1017
+ // Process innermost z-for elements first (no nested z-for inside)
1018
+ let forEls = [...root.querySelectorAll('[z-for]')]
1019
+ .filter(el => !el.querySelector('[z-for]'));
1020
+ if (!forEls.length) return;
1021
+
1022
+ for (const el of forEls) {
1023
+ if (!el.parentNode) continue; // already removed
1024
+ const expr = el.getAttribute('z-for');
1025
+ const m = expr.match(
1026
+ /^\s*(?:\(\s*(\w+)(?:\s*,\s*(\w+))?\s*\)|(\w+))\s+in\s+(.+)\s*$/
1027
+ );
1028
+ if (!m) { el.removeAttribute('z-for'); continue; }
1029
+
1030
+ const itemVar = m[1] || m[3];
1031
+ const indexVar = m[2] || '$index';
1032
+ const listExpr = m[4].trim();
1033
+
1034
+ let list = this._evalExpr(listExpr);
1035
+ if (list == null) { el.remove(); continue; }
1036
+ // Number range: z-for="n in 5" → [1, 2, 3, 4, 5]
1037
+ if (typeof list === 'number') {
1038
+ list = Array.from({ length: list }, (_, i) => i + 1);
1039
+ }
1040
+ // Object iteration: z-for="(val, key) in obj" → entries
1041
+ if (!Array.isArray(list) && typeof list === 'object' && typeof list[Symbol.iterator] !== 'function') {
1042
+ list = Object.entries(list).map(([k, v]) => ({ key: k, value: v }));
1043
+ }
1044
+ if (!Array.isArray(list) && typeof list[Symbol.iterator] === 'function') {
1045
+ list = [...list];
1046
+ }
1047
+ if (!Array.isArray(list)) { el.remove(); continue; }
1048
+
1049
+ const parent = el.parentNode;
1050
+ const tplEl = el.cloneNode(true);
1051
+ tplEl.removeAttribute('z-for');
1052
+ const tplOuter = tplEl.outerHTML;
1053
+
1054
+ const fragment = document.createDocumentFragment();
1055
+ const evalReplace = (str, item, index) =>
1056
+ str.replace(/\{\{(.+?)\}\}/g, (_, inner) => {
1057
+ try {
1058
+ const loopScope = {};
1059
+ loopScope[itemVar] = item;
1060
+ loopScope[indexVar] = index;
1061
+ const result = safeEval(inner.trim(), [
1062
+ loopScope,
1063
+ this.state.__raw || this.state,
1064
+ { props: this.props, computed: this.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
1065
+ ]);
1066
+ return result != null ? escapeHtml(String(result)) : '';
1067
+ } catch { return ''; }
1068
+ });
1069
+
1070
+ for (let i = 0; i < list.length; i++) {
1071
+ const processed = evalReplace(tplOuter, list[i], i);
1072
+ const wrapper = document.createElement('div');
1073
+ wrapper.innerHTML = processed;
1074
+ while (wrapper.firstChild) fragment.appendChild(wrapper.firstChild);
1075
+ }
1076
+
1077
+ parent.replaceChild(fragment, el);
1078
+ }
1079
+
1080
+ // Handle remaining nested z-for (now exposed)
1081
+ if (root.querySelector('[z-for]')) _recurse(root);
1082
+ };
1083
+
1084
+ _recurse(temp);
1085
+ return temp.innerHTML;
1086
+ }
1087
+
1088
+ // ---------------------------------------------------------------------------
1089
+ // _expandContentDirectives - Pre-morph z-html & z-text expansion
1090
+ //
1091
+ // Evaluates z-html and z-text directives at the string level so the morph
1092
+ // engine receives HTML with the actual content inline. This lets the diff
1093
+ // algorithm properly compare old vs new content (text nodes, child elements)
1094
+ // instead of clearing + re-injecting on every re-render.
1095
+ //
1096
+ // Same parse → evaluate → serialize pattern as _expandZFor.
1097
+ // ---------------------------------------------------------------------------
1098
+ _expandContentDirectives(html) {
1099
+ if (!html.includes('z-html') && !html.includes('z-text')) return html;
1100
+
1101
+ const temp = document.createElement('div');
1102
+ temp.innerHTML = html;
1103
+
1104
+ // z-html: evaluate expression → inject as innerHTML
1105
+ temp.querySelectorAll('[z-html]').forEach(el => {
1106
+ if (el.closest('[z-pre]')) return;
1107
+ const val = this._evalExpr(el.getAttribute('z-html'));
1108
+ el.innerHTML = val != null ? String(val) : '';
1109
+ el.removeAttribute('z-html');
1110
+ });
1111
+
1112
+ // z-text: evaluate expression → inject as textContent (HTML-safe)
1113
+ temp.querySelectorAll('[z-text]').forEach(el => {
1114
+ if (el.closest('[z-pre]')) return;
1115
+ const val = this._evalExpr(el.getAttribute('z-text'));
1116
+ el.textContent = val != null ? String(val) : '';
1117
+ el.removeAttribute('z-text');
1118
+ });
1119
+
1120
+ return temp.innerHTML;
1121
+ }
1122
+
1123
+ // ---------------------------------------------------------------------------
1124
+ // _processDirectives - Post-innerHTML DOM-level directive processing
1125
+ // ---------------------------------------------------------------------------
1126
+ _processDirectives() {
1127
+ // z-pre: skip all directive processing on subtrees
1128
+ // (we leave z-pre elements in the DOM, but skip their descendants)
1129
+
1130
+ // -- z-if / z-else-if / z-else (conditional rendering) --------
1131
+ const ifEls = [...this._el.querySelectorAll('[z-if]')];
1132
+ for (const el of ifEls) {
1133
+ if (!el.parentNode || el.closest('[z-pre]')) continue;
1134
+
1135
+ const show = !!this._evalExpr(el.getAttribute('z-if'));
1136
+
1137
+ // Collect chain: adjacent z-else-if / z-else siblings
1138
+ const chain = [{ el, show }];
1139
+ let sib = el.nextElementSibling;
1140
+ while (sib) {
1141
+ if (sib.hasAttribute('z-else-if')) {
1142
+ chain.push({ el: sib, show: !!this._evalExpr(sib.getAttribute('z-else-if')) });
1143
+ sib = sib.nextElementSibling;
1144
+ } else if (sib.hasAttribute('z-else')) {
1145
+ chain.push({ el: sib, show: true });
1146
+ break;
1147
+ } else {
1148
+ break;
1149
+ }
1150
+ }
1151
+
1152
+ // Keep the first truthy branch, remove the rest
1153
+ let found = false;
1154
+ for (const item of chain) {
1155
+ if (!found && item.show) {
1156
+ found = true;
1157
+ item.el.removeAttribute('z-if');
1158
+ item.el.removeAttribute('z-else-if');
1159
+ item.el.removeAttribute('z-else');
1160
+ // Transition enter for z-if elements becoming visible
1161
+ const transName = item.el.getAttribute('z-transition');
1162
+ if (transName) {
1163
+ item.el.removeAttribute('z-transition');
1164
+ this._transitionEnter(item.el, transName);
1165
+ }
1166
+ } else {
1167
+ // Transition leave for z-if elements being removed
1168
+ const transName = item.el.getAttribute('z-transition');
1169
+ if (transName) {
1170
+ this._transitionLeave(item.el, transName, () => item.el.remove());
1171
+ } else {
1172
+ item.el.remove();
1173
+ }
1174
+ }
1175
+ }
1176
+ }
1177
+
1178
+ // -- z-show (toggle display) -----------------------------------
1179
+ this._el.querySelectorAll('[z-show]').forEach(el => {
1180
+ if (el.closest('[z-pre]')) return;
1181
+ const show = !!this._evalExpr(el.getAttribute('z-show'));
1182
+ const transName = el.getAttribute('z-transition');
1183
+ const wasHidden = el.style.display === 'none' || el.hasAttribute('data-zq-hidden');
1184
+
1185
+ if (transName) {
1186
+ el.removeAttribute('z-show');
1187
+ if (show && wasHidden) {
1188
+ // Entering: was hidden, now showing
1189
+ el.style.display = '';
1190
+ el.removeAttribute('data-zq-hidden');
1191
+ this._transitionEnter(el, transName);
1192
+ } else if (!show && !wasHidden) {
1193
+ // Leaving: was visible, now hiding
1194
+ el.setAttribute('data-zq-hidden', '');
1195
+ this._transitionLeave(el, transName, () => {
1196
+ el.style.display = 'none';
1197
+ });
1198
+ } else {
1199
+ el.style.display = show ? '' : 'none';
1200
+ if (!show) el.setAttribute('data-zq-hidden', '');
1201
+ else el.removeAttribute('data-zq-hidden');
1202
+ }
1203
+ } else {
1204
+ el.style.display = show ? '' : 'none';
1205
+ el.removeAttribute('z-show');
1206
+ }
1207
+ });
1208
+
1209
+ // -- z-bind:attr / :attr (dynamic attribute binding) -----------
1210
+ // Use TreeWalker instead of querySelectorAll('*') - avoids
1211
+ // creating a flat array of every single descendant element.
1212
+ // TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
1213
+ // at the walker level (faster than per-node closest('[z-pre]') checks).
1214
+ const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
1215
+ acceptNode(n) {
1216
+ return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
1217
+ }
1218
+ });
1219
+ let node;
1220
+ while ((node = walker.nextNode())) {
1221
+ const attrs = node.attributes;
1222
+ for (let i = attrs.length - 1; i >= 0; i--) {
1223
+ const attr = attrs[i];
1224
+ let attrName;
1225
+ if (attr.name.startsWith('z-bind:')) attrName = attr.name.slice(7);
1226
+ else if (attr.name.charCodeAt(0) === 58 && attr.name.charCodeAt(1) !== 58) attrName = attr.name.slice(1); // ':' but not '::'
1227
+ else continue;
1228
+
1229
+ const val = this._evalExpr(attr.value);
1230
+ node.removeAttribute(attr.name);
1231
+ if (val === false || val === null || val === undefined) {
1232
+ node.removeAttribute(attrName);
1233
+ } else if (val === true) {
1234
+ node.setAttribute(attrName, '');
1235
+ } else {
1236
+ node.setAttribute(attrName, String(val));
1237
+ }
1238
+ }
1239
+ }
1240
+
1241
+ // -- z-class (dynamic class binding) ---------------------------
1242
+ this._el.querySelectorAll('[z-class]').forEach(el => {
1243
+ if (el.closest('[z-pre]')) return;
1244
+ const val = this._evalExpr(el.getAttribute('z-class'));
1245
+ if (typeof val === 'string') {
1246
+ val.split(/\s+/).filter(Boolean).forEach(c => el.classList.add(c));
1247
+ } else if (Array.isArray(val)) {
1248
+ val.filter(Boolean).forEach(c => el.classList.add(String(c)));
1249
+ } else if (val && typeof val === 'object') {
1250
+ for (const [cls, active] of Object.entries(val)) {
1251
+ el.classList.toggle(cls, !!active);
1252
+ }
1253
+ }
1254
+ el.removeAttribute('z-class');
1255
+ });
1256
+
1257
+ // -- z-style (dynamic inline styles) ---------------------------
1258
+ this._el.querySelectorAll('[z-style]').forEach(el => {
1259
+ if (el.closest('[z-pre]')) return;
1260
+ const val = this._evalExpr(el.getAttribute('z-style'));
1261
+ if (typeof val === 'string') {
1262
+ el.style.cssText += ';' + val;
1263
+ } else if (val && typeof val === 'object') {
1264
+ for (const [prop, v] of Object.entries(val)) {
1265
+ el.style[prop] = v;
1266
+ }
1267
+ }
1268
+ el.removeAttribute('z-style');
1269
+ });
1270
+
1271
+ // -- z-stream (assign MediaStream to <video>/<audio>.srcObject) -
1272
+ this._el.querySelectorAll('[z-stream]').forEach(el => {
1273
+ if (el.closest('[z-pre]')) return;
1274
+ const val = this._evalExpr(el.getAttribute('z-stream'));
1275
+ const hasMediaStream = typeof MediaStream !== 'undefined';
1276
+ if (val == null) {
1277
+ el.srcObject = null;
1278
+ } else if (hasMediaStream && val instanceof MediaStream) {
1279
+ el.srcObject = val;
1280
+ } else if (val && typeof val.getTracks === 'function') {
1281
+ // Accept duck-typed stream objects (test fakes, polyfills).
1282
+ el.srcObject = val;
1283
+ } else {
1284
+ el.srcObject = null;
1285
+ }
1286
+ el.removeAttribute('z-stream');
1287
+ });
1288
+
1289
+ // z-html and z-text are now pre-expanded at string level (before
1290
+ // morph) via _expandContentDirectives(), so the diff engine can
1291
+ // properly diff their content instead of clearing + re-injecting.
1292
+
1293
+ // -- z-cloak (remove after render) -----------------------------
1294
+ this._el.querySelectorAll('[z-cloak]').forEach(el => {
1295
+ el.removeAttribute('z-cloak');
1296
+ });
1297
+ }
1298
+
1299
+ // ---------------------------------------------------------------------------
1300
+ // Transition helpers - CSS class-based enter/leave animations
1301
+ //
1302
+ // z-transition="fade" generates:
1303
+ // Enter: .fade-enter-from .fade-enter-active + .fade-enter-to
1304
+ // Leave: .fade-leave-from → .fade-leave-active + .fade-leave-to
1305
+ //
1306
+ // Or component-level transition config:
1307
+ // transition: { enter: 'animate-in', leave: 'animate-out', duration: 200 }
1308
+ // ---------------------------------------------------------------------------
1309
+
1310
+ /**
1311
+ * Run an enter transition on an element.
1312
+ * @param {Element} el - target element
1313
+ * @param {string} name - transition name (e.g. 'fade')
1314
+ */
1315
+ _transitionEnter(el, name) {
1316
+ // Check for component-level transition config
1317
+ const cfg = this._def.transition;
1318
+ if (cfg && cfg.enter) {
1319
+ el.classList.add(cfg.enter);
1320
+ const duration = cfg.duration || 0;
1321
+ const cleanup = () => el.classList.remove(cfg.enter);
1322
+ if (duration > 0) {
1323
+ setTimeout(cleanup, duration);
1324
+ } else {
1325
+ el.addEventListener('transitionend', cleanup, { once: true });
1326
+ el.addEventListener('animationend', cleanup, { once: true });
1327
+ }
1328
+ return;
1329
+ }
1330
+
1331
+ // CSS class-based transition pattern
1332
+ el.classList.add(`${name}-enter-from`, `${name}-enter-active`);
1333
+ // Force reflow so the browser registers the initial state
1334
+ void el.offsetHeight;
1335
+ requestAnimationFrame(() => {
1336
+ el.classList.remove(`${name}-enter-from`);
1337
+ el.classList.add(`${name}-enter-to`);
1338
+ const onEnd = () => {
1339
+ el.classList.remove(`${name}-enter-active`, `${name}-enter-to`);
1340
+ };
1341
+ el.addEventListener('transitionend', onEnd, { once: true });
1342
+ el.addEventListener('animationend', onEnd, { once: true });
1343
+ });
1344
+ }
1345
+
1346
+ /**
1347
+ * Run a leave transition on an element, then call done().
1348
+ * @param {Element} el - target element
1349
+ * @param {string} name - transition name (e.g. 'fade')
1350
+ * @param {Function} done - callback when transition completes
1351
+ */
1352
+ _transitionLeave(el, name, done) {
1353
+ // Check for component-level transition config
1354
+ const cfg = this._def.transition;
1355
+ if (cfg && cfg.leave) {
1356
+ el.classList.add(cfg.leave);
1357
+ const duration = cfg.duration || 0;
1358
+ const cleanup = () => {
1359
+ el.classList.remove(cfg.leave);
1360
+ done();
1361
+ };
1362
+ if (duration > 0) {
1363
+ setTimeout(cleanup, duration);
1364
+ } else {
1365
+ el.addEventListener('transitionend', cleanup, { once: true });
1366
+ el.addEventListener('animationend', cleanup, { once: true });
1367
+ }
1368
+ return;
1369
+ }
1370
+
1371
+ // CSS class-based transition pattern
1372
+ el.classList.add(`${name}-leave-from`, `${name}-leave-active`);
1373
+ void el.offsetHeight;
1374
+ requestAnimationFrame(() => {
1375
+ el.classList.remove(`${name}-leave-from`);
1376
+ el.classList.add(`${name}-leave-to`);
1377
+ const onEnd = () => {
1378
+ el.classList.remove(`${name}-leave-active`, `${name}-leave-to`);
1379
+ done();
1380
+ };
1381
+ el.addEventListener('transitionend', onEnd, { once: true });
1382
+ el.addEventListener('animationend', onEnd, { once: true });
1383
+ });
1384
+ }
1385
+
1386
+ // Programmatic state update (batch-friendly)
1387
+ // Passing an empty object forces a re-render (useful for external state changes).
1388
+ setState(partial) {
1389
+ if (partial && Object.keys(partial).length > 0) {
1390
+ Object.assign(this.state, partial);
1391
+ } else {
1392
+ this._scheduleUpdate();
1393
+ }
1394
+ }
1395
+
1396
+ // Emit custom event up the DOM
1397
+ emit(name, detail) {
1398
+ this._el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, cancelable: true }));
1399
+ }
1400
+
1401
+ // Destroy this component
1402
+ destroy() {
1403
+ if (this._destroyed) return;
1404
+ this._destroyed = true;
1405
+ if (this._def.destroyed) {
1406
+ try { this._def.destroyed.call(this); }
1407
+ catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" destroyed() threw`, { component: this._def._name }, err); }
1408
+ }
1409
+ // Clean up prop observer
1410
+ if (this._propObserver) {
1411
+ this._propObserver.disconnect();
1412
+ this._propObserver = null;
1413
+ }
1414
+ // Clean up store connectors
1415
+ if (this._storeCleanups) {
1416
+ this._storeCleanups.forEach(fn => fn());
1417
+ this._storeCleanups = [];
1418
+ }
1419
+ this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
1420
+ this._listeners = [];
1421
+ if (this._outsideListeners) {
1422
+ this._outsideListeners.forEach(({ event, handler }) => document.removeEventListener(event, handler, true));
1423
+ this._outsideListeners = [];
1424
+ }
1425
+ this._delegatedEvents = null;
1426
+ this._eventBindings = null;
1427
+ // Clear any pending debounce/throttle timers to prevent stale closures.
1428
+ // Timers are keyed by individual child elements, so iterate all descendants.
1429
+ const allEls = this._el.querySelectorAll('*');
1430
+ allEls.forEach(child => {
1431
+ const dTimers = _debounceTimers.get(child);
1432
+ if (dTimers) {
1433
+ for (const key in dTimers) clearTimeout(dTimers[key]);
1434
+ _debounceTimers.delete(child);
1435
+ }
1436
+ const tTimers = _throttleTimers.get(child);
1437
+ if (tTimers) {
1438
+ for (const key in tTimers) clearTimeout(tTimers[key]);
1439
+ _throttleTimers.delete(child);
1440
+ }
1441
+ });
1442
+ if (this._styleEl) this._styleEl.remove();
1443
+ _instances.delete(this._el);
1444
+ this._el.innerHTML = '';
1445
+ }
1446
+ }
1447
+
1448
+
1449
+ // Reserved definition keys (not user methods)
1450
+ const _reservedKeys = new Set([
1451
+ 'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
1452
+ 'templateUrl', 'styleUrl', 'templates', 'base',
1453
+ 'computed', 'watch', 'stores', 'transition', 'activated', 'deactivated'
1454
+ ]);
1455
+
1456
+
1457
+ // ---------------------------------------------------------------------------
1458
+ // Public API
1459
+ // ---------------------------------------------------------------------------
1460
+
1461
+ /**
1462
+ * Register a component
1463
+ * @param {string} name - tag name (must contain a hyphen, e.g. 'app-counter')
1464
+ * @param {object} definition - component definition
1465
+ */
1466
+ export function component(name, definition) {
1467
+ if (!name || typeof name !== 'string') {
1468
+ throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, 'Component name must be a non-empty string');
1469
+ }
1470
+ if (!name.includes('-')) {
1471
+ throw new ZQueryError(ErrorCode.COMP_INVALID_NAME, `Component name "${name}" must contain a hyphen (Web Component convention)`);
1472
+ }
1473
+ definition._name = name;
1474
+
1475
+ // Auto-detect the calling module's URL so that relative templateUrl
1476
+ // and styleUrl paths resolve relative to the component file.
1477
+ // An explicit `base` string on the definition overrides auto-detection.
1478
+ if (definition.base !== undefined) {
1479
+ definition._base = definition.base; // explicit override
1480
+ } else {
1481
+ definition._base = _detectCallerBase();
1482
+ }
1483
+
1484
+ _registry.set(name, definition);
1485
+ }
1486
+
1487
+ /**
1488
+ * Mount a component into a target element
1489
+ * @param {string|Element} target - selector or element to mount into
1490
+ * @param {string} componentName - registered component name
1491
+ * @param {object} props - props to pass
1492
+ * @returns {Component}
1493
+ */
1494
+ export function mount(target, componentName, props = {}) {
1495
+ const el = typeof target === 'string' ? document.querySelector(target) : target;
1496
+ if (!el) throw new ZQueryError(ErrorCode.COMP_MOUNT_TARGET, `Mount target "${target}" not found`, { target });
1497
+
1498
+ const def = _registry.get(componentName);
1499
+ if (!def) throw new ZQueryError(ErrorCode.COMP_NOT_FOUND, `Component "${componentName}" not registered`, { component: componentName });
1500
+
1501
+ // Destroy existing instance
1502
+ if (_instances.has(el)) _instances.get(el).destroy();
1503
+
1504
+ const instance = new Component(el, def, props);
1505
+ _instances.set(el, instance);
1506
+ instance._render();
1507
+ return instance;
1508
+ }
1509
+
1510
+ /**
1511
+ * Scan a container for custom component tags and auto-mount them
1512
+ * @param {Element} root - root element to scan (default: document.body)
1513
+ */
1514
+ export function mountAll(root = document.body) {
1515
+ for (const [name, def] of _registry) {
1516
+ const tags = root.querySelectorAll(name);
1517
+ tags.forEach(tag => {
1518
+ if (_instances.has(tag)) return; // Already mounted
1519
+
1520
+ // Extract props from attributes
1521
+ const props = {};
1522
+
1523
+ // Find parent component instance for evaluating dynamic prop expressions
1524
+ let parentInstance = null;
1525
+ let ancestor = tag.parentElement;
1526
+ while (ancestor) {
1527
+ if (_instances.has(ancestor)) {
1528
+ parentInstance = _instances.get(ancestor);
1529
+ break;
1530
+ }
1531
+ ancestor = ancestor.parentElement;
1532
+ }
1533
+
1534
+ [...tag.attributes].forEach(attr => {
1535
+ if (attr.name.startsWith('@') || attr.name.startsWith('z-')) return;
1536
+
1537
+ // Dynamic prop: :propName="expression" - evaluate in parent context
1538
+ if (attr.name.startsWith(':')) {
1539
+ const propName = attr.name.slice(1);
1540
+ if (parentInstance) {
1541
+ props[propName] = safeEval(attr.value, [
1542
+ parentInstance.state.__raw || parentInstance.state,
1543
+ { props: parentInstance.props, refs: parentInstance.refs, computed: parentInstance.computed, $: typeof window !== 'undefined' ? window.$ : undefined }
1544
+ ]);
1545
+ } else {
1546
+ // No parent - try JSON parse
1547
+ try { props[propName] = JSON.parse(attr.value); }
1548
+ catch { props[propName] = attr.value; }
1549
+ }
1550
+ return;
1551
+ }
1552
+
1553
+ // Static prop
1554
+ try { props[attr.name] = JSON.parse(attr.value); }
1555
+ catch { props[attr.name] = attr.value; }
1556
+ });
1557
+
1558
+ const instance = new Component(tag, def, props);
1559
+ _instances.set(tag, instance);
1560
+ instance._render();
1561
+ });
1562
+ }
1563
+ }
1564
+
1565
+ /**
1566
+ * Get the component instance for an element
1567
+ * @param {string|Element} target
1568
+ * @returns {Component|null}
1569
+ */
1570
+ export function getInstance(target) {
1571
+ const el = typeof target === 'string' ? document.querySelector(target) : target;
1572
+ return _instances.get(el) || null;
1573
+ }
1574
+
1575
+ /**
1576
+ * Destroy a component at the given target
1577
+ * @param {string|Element} target
1578
+ */
1579
+ export function destroy(target) {
1580
+ const el = typeof target === 'string' ? document.querySelector(target) : target;
1581
+ const inst = _instances.get(el);
1582
+ if (inst) inst.destroy();
1583
+ }
1584
+
1585
+ /**
1586
+ * Get the registry (for debugging)
1587
+ */
1588
+ export function getRegistry() {
1589
+ return Object.fromEntries(_registry);
1590
+ }
1591
+
1592
+ /**
1593
+ * Pre-load a component's external templates and styles so the next mount
1594
+ * renders synchronously (no blank flash while fetching).
1595
+ * Safe to call multiple times - skips if already loaded.
1596
+ * @param {string} name - registered component name
1597
+ * @returns {Promise<void>}
1598
+ */
1599
+ export async function prefetch(name) {
1600
+ const def = _registry.get(name);
1601
+ if (!def) return;
1602
+
1603
+ // Load templateUrl and styleUrl if not already loaded.
1604
+ if ((def.templateUrl && !def._templateLoaded) ||
1605
+ (def.styleUrl && !def._styleLoaded)) {
1606
+ await Component.prototype._loadExternals.call({ _def: def });
1607
+ }
1608
+ }
1609
+
1610
+
1611
+ // ---------------------------------------------------------------------------
1612
+ // Global stylesheet loader
1613
+ // ---------------------------------------------------------------------------
1614
+ const _globalStyles = new Map(); // url → <link> element
1615
+
1616
+ /**
1617
+ * Load one or more global stylesheets into <head>.
1618
+ * Relative URLs resolve against the calling module's directory (auto-detected
1619
+ * from the stack trace), just like component styleUrl paths.
1620
+ * Returns a remove() handle so the caller can unload if needed.
1621
+ *
1622
+ * $.style('app.css') // critical by default
1623
+ * $.style(['app.css', 'theme.css']) // multiple files
1624
+ * $.style('/assets/global.css') // absolute - used as-is
1625
+ * $.style('app.css', { critical: false }) // opt out of FOUC prevention
1626
+ *
1627
+ * Options:
1628
+ * critical - (boolean, default true) When true, zQuery injects a tiny
1629
+ * inline style that hides the page (`visibility: hidden`) and
1630
+ * removes it once the stylesheet has loaded. This prevents
1631
+ * FOUC (Flash of Unstyled Content) entirely - no special
1632
+ * markup needed in the HTML file. Set to false to load
1633
+ * the stylesheet without blocking paint.
1634
+ * bg - (string, default '#0d1117') Background color applied while
1635
+ * the page is hidden during critical load. Prevents a white
1636
+ * flash on dark-themed apps. Only used when critical is true.
1637
+ *
1638
+ * Duplicate URLs are ignored (idempotent).
1639
+ *
1640
+ * @param {string|string[]} urls - stylesheet URL(s) to load
1641
+ * @param {object} [opts] - options
1642
+ * @param {boolean} [opts.critical=true] - hide page until loaded (prevents FOUC)
1643
+ * @param {string} [opts.bg] - background color while hidden (default '#0d1117')
1644
+ * @returns {{ remove: Function, ready: Promise }} - .remove() to unload, .ready resolves when loaded
1645
+ */
1646
+ export function style(urls, opts = {}) {
1647
+ const callerBase = _detectCallerBase();
1648
+ const list = Array.isArray(urls) ? urls : [urls];
1649
+ const elements = [];
1650
+ const loadPromises = [];
1651
+
1652
+ // Critical mode (default: true): inject a tiny inline <style> that hides the
1653
+ // page and sets a background color. Fully self-contained - no markup needed
1654
+ // in the HTML file. The style is removed once the sheet loads.
1655
+ let _criticalStyle = null;
1656
+ if (opts.critical !== false) {
1657
+ _criticalStyle = document.createElement('style');
1658
+ _criticalStyle.setAttribute('data-zq-critical', '');
1659
+ _criticalStyle.textContent = `html{visibility:hidden!important;background:${opts.bg || '#0d1117'}}`;
1660
+ document.head.insertBefore(_criticalStyle, document.head.firstChild);
1661
+ }
1662
+
1663
+ for (let url of list) {
1664
+ // Resolve relative paths against the caller's directory first,
1665
+ // falling back to <base href> or origin root.
1666
+ if (typeof url === 'string' && !url.startsWith('/') && !url.includes(':') && !url.startsWith('//')) {
1667
+ url = _resolveUrl(url, callerBase);
1668
+ }
1669
+
1670
+ if (_globalStyles.has(url)) {
1671
+ elements.push(_globalStyles.get(url));
1672
+ continue;
1673
+ }
1674
+
1675
+ const link = document.createElement('link');
1676
+ link.rel = 'stylesheet';
1677
+ link.href = url;
1678
+ link.setAttribute('data-zq-style', '');
1679
+
1680
+ const p = new Promise(resolve => {
1681
+ link.onload = resolve;
1682
+ link.onerror = resolve; // don't block forever on error
1683
+ });
1684
+ loadPromises.push(p);
1685
+
1686
+ document.head.appendChild(link);
1687
+ _globalStyles.set(url, link);
1688
+ elements.push(link);
1689
+ }
1690
+
1691
+ // When all sheets are loaded, reveal the page if critical mode was used
1692
+ const ready = Promise.all(loadPromises).then(() => {
1693
+ if (_criticalStyle) {
1694
+ _criticalStyle.remove();
1695
+ }
1696
+ });
1697
+
1698
+ return {
1699
+ ready,
1700
+ remove() {
1701
+ for (const el of elements) {
1702
+ el.remove();
1703
+ for (const [k, v] of _globalStyles) {
1704
+ if (v === el) { _globalStyles.delete(k); break; }
1705
+ }
1706
+ }
1707
+ }
1708
+ };
1709
+ }