zero-query 1.1.1 → 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.
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -442
- package/cli/commands/build.js +254 -247
- package/cli/commands/bundle.js +1228 -1224
- package/cli/commands/create.js +137 -121
- package/cli/commands/dev/devtools/index.js +56 -56
- package/cli/commands/dev/devtools/js/components.js +49 -49
- package/cli/commands/dev/devtools/js/core.js +423 -423
- package/cli/commands/dev/devtools/js/elements.js +421 -421
- package/cli/commands/dev/devtools/js/network.js +166 -166
- package/cli/commands/dev/devtools/js/performance.js +73 -73
- package/cli/commands/dev/devtools/js/router.js +105 -105
- package/cli/commands/dev/devtools/js/source.js +132 -132
- package/cli/commands/dev/devtools/js/stats.js +35 -35
- package/cli/commands/dev/devtools/js/tabs.js +79 -79
- package/cli/commands/dev/devtools/panel.html +95 -95
- package/cli/commands/dev/devtools/styles.css +244 -244
- package/cli/commands/dev/index.js +107 -107
- package/cli/commands/dev/logger.js +75 -75
- package/cli/commands/dev/overlay.js +858 -858
- package/cli/commands/dev/server.js +220 -220
- package/cli/commands/dev/validator.js +94 -94
- package/cli/commands/dev/watcher.js +172 -172
- package/cli/help.js +114 -112
- package/cli/index.js +52 -52
- package/cli/scaffold/default/LICENSE +21 -21
- package/cli/scaffold/default/app/app.js +207 -207
- package/cli/scaffold/default/app/components/about.js +201 -201
- package/cli/scaffold/default/app/components/api-demo.js +143 -143
- package/cli/scaffold/default/app/components/contact-card.js +231 -231
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
- package/cli/scaffold/default/app/components/counter.js +127 -127
- package/cli/scaffold/default/app/components/home.js +249 -249
- package/cli/scaffold/default/app/components/not-found.js +16 -16
- package/cli/scaffold/default/app/components/playground/playground.css +115 -115
- package/cli/scaffold/default/app/components/playground/playground.html +161 -161
- package/cli/scaffold/default/app/components/playground/playground.js +116 -116
- package/cli/scaffold/default/app/components/todos.js +225 -225
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
- package/cli/scaffold/default/app/routes.js +15 -15
- package/cli/scaffold/default/app/store.js +101 -101
- package/cli/scaffold/default/global.css +552 -552
- package/cli/scaffold/default/index.html +99 -99
- package/cli/scaffold/minimal/app/app.js +85 -85
- package/cli/scaffold/minimal/app/components/about.js +68 -68
- package/cli/scaffold/minimal/app/components/counter.js +122 -122
- package/cli/scaffold/minimal/app/components/home.js +68 -68
- package/cli/scaffold/minimal/app/components/not-found.js +16 -16
- package/cli/scaffold/minimal/app/routes.js +9 -9
- package/cli/scaffold/minimal/app/store.js +36 -36
- package/cli/scaffold/minimal/global.css +300 -300
- package/cli/scaffold/minimal/index.html +44 -44
- package/cli/scaffold/ssr/app/app.js +41 -41
- package/cli/scaffold/ssr/app/components/about.js +55 -55
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
- package/cli/scaffold/ssr/app/components/home.js +37 -37
- package/cli/scaffold/ssr/app/components/not-found.js +15 -15
- package/cli/scaffold/ssr/app/routes.js +8 -8
- package/cli/scaffold/ssr/global.css +228 -228
- package/cli/scaffold/ssr/index.html +37 -37
- package/cli/scaffold/ssr/package.json +8 -8
- package/cli/scaffold/ssr/server/data/posts.js +144 -144
- package/cli/scaffold/ssr/server/index.js +213 -213
- package/cli/scaffold/webrtc/app/app.js +11 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
- package/cli/scaffold/webrtc/app/lib/room.js +252 -0
- package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
- package/cli/scaffold/webrtc/global.css +250 -0
- package/cli/scaffold/webrtc/index.html +21 -0
- package/cli/utils.js +305 -287
- package/dist/API.md +661 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6614
- package/dist/zquery.min.js +8 -631
- package/index.d.ts +570 -371
- package/index.js +311 -240
- package/package.json +76 -70
- package/src/component.js +1709 -1691
- package/src/core.js +921 -921
- package/src/diff.js +497 -497
- package/src/errors.js +209 -209
- package/src/expression.js +922 -922
- package/src/http.js +242 -242
- package/src/package.json +1 -1
- package/src/reactive.js +255 -255
- package/src/router.js +843 -843
- package/src/ssr.js +418 -418
- package/src/store.js +318 -318
- package/src/utils.js +515 -515
- package/src/webrtc/e2ee.js +351 -0
- package/src/webrtc/errors.js +116 -0
- package/src/webrtc/ice.js +301 -0
- package/src/webrtc/index.js +131 -0
- package/src/webrtc/joinToken.js +119 -0
- package/src/webrtc/observe.js +172 -0
- package/src/webrtc/peer.js +351 -0
- package/src/webrtc/reactive.js +268 -0
- package/src/webrtc/room.js +625 -0
- package/src/webrtc/sdp.js +302 -0
- package/src/webrtc/sfu/index.js +43 -0
- package/src/webrtc/sfu/livekit.js +131 -0
- package/src/webrtc/sfu/mediasoup.js +150 -0
- package/src/webrtc/signaling.js +373 -0
- package/src/webrtc/turn.js +237 -0
- package/tests/_helpers/webrtcFakes.js +289 -0
- package/tests/audit.test.js +4158 -4158
- package/tests/cli.test.js +1136 -1103
- package/tests/compare.test.js +497 -486
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -489
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -1650
- package/tests/electron-features.test.js +864 -864
- package/tests/errors.test.js +619 -619
- package/tests/expression.test.js +1056 -1056
- package/tests/http.test.js +648 -648
- package/tests/reactive.test.js +819 -819
- package/tests/router.test.js +2327 -2327
- package/tests/ssr.test.js +870 -870
- package/tests/store.test.js +830 -830
- package/tests/test-minifier.js +153 -153
- package/tests/test-ssr.js +27 -27
- package/tests/utils.test.js +1377 -1377
- package/tests/webrtc/e2ee.test.js +283 -0
- package/tests/webrtc/ice.test.js +202 -0
- package/tests/webrtc/joinToken.test.js +89 -0
- package/tests/webrtc/observe.test.js +111 -0
- package/tests/webrtc/peer.test.js +373 -0
- package/tests/webrtc/reactive.test.js +235 -0
- package/tests/webrtc/room.test.js +406 -0
- package/tests/webrtc/sdp.test.js +151 -0
- package/tests/webrtc/sfu-livekit.test.js +119 -0
- package/tests/webrtc/sfu.test.js +160 -0
- package/tests/webrtc/signaling.test.js +251 -0
- package/tests/webrtc/turn.test.js +256 -0
- package/types/collection.d.ts +383 -383
- package/types/component.d.ts +186 -186
- package/types/errors.d.ts +135 -135
- package/types/http.d.ts +92 -92
- package/types/misc.d.ts +201 -201
- package/types/reactive.d.ts +98 -98
- package/types/router.d.ts +190 -190
- package/types/ssr.d.ts +102 -102
- package/types/store.d.ts +146 -146
- package/types/utils.d.ts +245 -245
- package/types/webrtc.d.ts +653 -0
package/src/component.js
CHANGED
|
@@ -1,1691 +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 - 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-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
el.classList.add(
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
//
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
if (this.
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
//
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
this.
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
if (
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
*
|
|
1569
|
-
*/
|
|
1570
|
-
export function
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
*
|
|
1577
|
-
*
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1626
|
-
*
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
+
}
|