zero-query 0.1.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 -0
- package/README.md +1271 -0
- package/index.js +173 -0
- package/package.json +48 -0
- package/src/component.js +800 -0
- package/src/core.js +508 -0
- package/src/http.js +177 -0
- package/src/reactive.js +125 -0
- package/src/router.js +334 -0
- package/src/store.js +169 -0
- package/src/utils.js +271 -0
package/src/component.js
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zQuery Component — Lightweight reactive component system
|
|
3
|
+
*
|
|
4
|
+
* Declarative components using template literals (no JSX, no build step).
|
|
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, styleUrl, and pages.dir
|
|
19
|
+
* resolve relative to the component file automatically
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { reactive } from './reactive.js';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Component registry & external resource cache
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
const _registry = new Map(); // name → definition
|
|
28
|
+
const _instances = new Map(); // element → instance
|
|
29
|
+
const _resourceCache = new Map(); // url → Promise<string>
|
|
30
|
+
|
|
31
|
+
// Unique ID counter
|
|
32
|
+
let _uid = 0;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetch and cache a text resource (HTML template or CSS file).
|
|
36
|
+
* @param {string} url — URL to fetch
|
|
37
|
+
* @returns {Promise<string>}
|
|
38
|
+
*/
|
|
39
|
+
function _fetchResource(url) {
|
|
40
|
+
if (_resourceCache.has(url)) return _resourceCache.get(url);
|
|
41
|
+
|
|
42
|
+
// Resolve relative URLs against <base href> or origin root.
|
|
43
|
+
// This prevents SPA route paths (e.g. /docs/advanced) from
|
|
44
|
+
// breaking relative resource URLs like 'scripts/components/foo.css'.
|
|
45
|
+
let resolvedUrl = url;
|
|
46
|
+
if (typeof url === 'string' && !url.startsWith('/') && !url.includes(':') && !url.startsWith('//')) {
|
|
47
|
+
try {
|
|
48
|
+
const baseEl = document.querySelector('base');
|
|
49
|
+
const root = baseEl ? baseEl.href : (window.location.origin + '/');
|
|
50
|
+
resolvedUrl = new URL(url, root).href;
|
|
51
|
+
} catch { /* keep original */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const promise = fetch(resolvedUrl).then(res => {
|
|
55
|
+
if (!res.ok) throw new Error(`zQuery: Failed to load resource "${url}" (${res.status})`);
|
|
56
|
+
return res.text();
|
|
57
|
+
});
|
|
58
|
+
_resourceCache.set(url, promise);
|
|
59
|
+
return promise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Convert a kebab-case id to Title Case.
|
|
64
|
+
* 'getting-started' → 'Getting Started'
|
|
65
|
+
* @param {string} id
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function _titleCase(id) {
|
|
69
|
+
return id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a relative URL against a base.
|
|
74
|
+
*
|
|
75
|
+
* - If `base` is an absolute URL (http/https/file), resolve directly.
|
|
76
|
+
* - If `base` is a relative path string, resolve it against the page root
|
|
77
|
+
* (or <base href>) first, then resolve `url` against that.
|
|
78
|
+
* - If `base` is falsy, return `url` unchanged — _fetchResource's own
|
|
79
|
+
* fallback (page root / <base href>) handles it.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} url — URL or relative path to resolve
|
|
82
|
+
* @param {string} [base] — auto-detected caller URL or explicit base path
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function _resolveUrl(url, base) {
|
|
86
|
+
if (!base || !url || typeof url !== 'string') return url;
|
|
87
|
+
// Already absolute — nothing to do
|
|
88
|
+
if (url.startsWith('/') || url.includes('://') || url.startsWith('//')) return url;
|
|
89
|
+
try {
|
|
90
|
+
if (base.includes('://')) {
|
|
91
|
+
// Absolute base (auto-detected module URL)
|
|
92
|
+
return new URL(url, base).href;
|
|
93
|
+
}
|
|
94
|
+
// Relative base string — resolve against page root first
|
|
95
|
+
const baseEl = document.querySelector('base');
|
|
96
|
+
const root = baseEl ? baseEl.href : (window.location.origin + '/');
|
|
97
|
+
const absBase = new URL(base.endsWith('/') ? base : base + '/', root).href;
|
|
98
|
+
return new URL(url, absBase).href;
|
|
99
|
+
} catch {
|
|
100
|
+
return url;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Capture the library's own script URL at load time for reliable filtering.
|
|
105
|
+
// This handles cases where the bundle is renamed (e.g., 'vendor.js').
|
|
106
|
+
let _ownScriptUrl;
|
|
107
|
+
try {
|
|
108
|
+
if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
|
|
109
|
+
_ownScriptUrl = document.currentScript.src.replace(/[?#].*$/, '');
|
|
110
|
+
}
|
|
111
|
+
} catch { /* ignored */ }
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Detect the URL of the module that called $.component().
|
|
115
|
+
* Parses Error().stack to find the first frame outside the zQuery bundle.
|
|
116
|
+
* Returns the directory URL (with trailing slash) or undefined.
|
|
117
|
+
* @returns {string|undefined}
|
|
118
|
+
*/
|
|
119
|
+
function _detectCallerBase() {
|
|
120
|
+
try {
|
|
121
|
+
const stack = new Error().stack || '';
|
|
122
|
+
const urls = stack.match(/(?:https?|file):\/\/[^\s\)]+/g) || [];
|
|
123
|
+
for (const raw of urls) {
|
|
124
|
+
// Strip line:col suffixes e.g. ":3:5" or ":12:1"
|
|
125
|
+
const url = raw.replace(/:\d+:\d+$/, '').replace(/:\d+$/, '');
|
|
126
|
+
// Skip the zQuery library itself — by filename pattern and captured URL
|
|
127
|
+
if (/zquery(\.min)?\.js$/i.test(url)) continue;
|
|
128
|
+
if (_ownScriptUrl && url.replace(/[?#].*$/, '') === _ownScriptUrl) continue;
|
|
129
|
+
// Return directory (strip filename, keep trailing slash)
|
|
130
|
+
return url.replace(/\/[^/]*$/, '/');
|
|
131
|
+
}
|
|
132
|
+
} catch { /* stack parsing unsupported — fall back silently */ }
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get a value from a nested object by dot-path.
|
|
138
|
+
* _getPath(obj, 'user.name') → obj.user.name
|
|
139
|
+
* @param {object} obj
|
|
140
|
+
* @param {string} path
|
|
141
|
+
* @returns {*}
|
|
142
|
+
*/
|
|
143
|
+
function _getPath(obj, path) {
|
|
144
|
+
return path.split('.').reduce((o, k) => o?.[k], obj);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Set a value on a nested object by dot-path, walking through proxy layers.
|
|
149
|
+
* _setPath(proxy, 'user.name', 'Tony') → proxy.user.name = 'Tony'
|
|
150
|
+
* @param {object} obj
|
|
151
|
+
* @param {string} path
|
|
152
|
+
* @param {*} value
|
|
153
|
+
*/
|
|
154
|
+
function _setPath(obj, path, value) {
|
|
155
|
+
const keys = path.split('.');
|
|
156
|
+
const last = keys.pop();
|
|
157
|
+
const target = keys.reduce((o, k) => (o && typeof o === 'object') ? o[k] : undefined, obj);
|
|
158
|
+
if (target && typeof target === 'object') target[last] = value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Component class
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
class Component {
|
|
166
|
+
constructor(el, definition, props = {}) {
|
|
167
|
+
this._uid = ++_uid;
|
|
168
|
+
this._el = el;
|
|
169
|
+
this._def = definition;
|
|
170
|
+
this._mounted = false;
|
|
171
|
+
this._destroyed = false;
|
|
172
|
+
this._updateQueued = false;
|
|
173
|
+
this._listeners = [];
|
|
174
|
+
|
|
175
|
+
// Refs map
|
|
176
|
+
this.refs = {};
|
|
177
|
+
|
|
178
|
+
// Props (read-only from parent)
|
|
179
|
+
this.props = Object.freeze({ ...props });
|
|
180
|
+
|
|
181
|
+
// Reactive state
|
|
182
|
+
const initialState = typeof definition.state === 'function'
|
|
183
|
+
? definition.state()
|
|
184
|
+
: { ...(definition.state || {}) };
|
|
185
|
+
|
|
186
|
+
this.state = reactive(initialState, () => {
|
|
187
|
+
if (!this._destroyed) this._scheduleUpdate();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Bind all user methods to this instance
|
|
191
|
+
for (const [key, val] of Object.entries(definition)) {
|
|
192
|
+
if (typeof val === 'function' && !_reservedKeys.has(key)) {
|
|
193
|
+
this[key] = val.bind(this);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Init lifecycle
|
|
198
|
+
if (definition.init) definition.init.call(this);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Schedule a batched DOM update (microtask)
|
|
202
|
+
_scheduleUpdate() {
|
|
203
|
+
if (this._updateQueued) return;
|
|
204
|
+
this._updateQueued = true;
|
|
205
|
+
queueMicrotask(() => {
|
|
206
|
+
this._updateQueued = false;
|
|
207
|
+
if (!this._destroyed) this._render();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Load external templateUrl / styleUrl if specified (once per definition)
|
|
212
|
+
//
|
|
213
|
+
// Relative paths are resolved automatically against the component file's
|
|
214
|
+
// own directory (auto-detected at registration time). You can override
|
|
215
|
+
// this with `base: 'some/path/'` on the definition.
|
|
216
|
+
//
|
|
217
|
+
// templateUrl accepts:
|
|
218
|
+
// - string → single template (used with {{expr}} interpolation)
|
|
219
|
+
// - string[] → array of URLs → indexed map via this.templates[0], …
|
|
220
|
+
// - { key: url, … } → named map → this.templates.key
|
|
221
|
+
//
|
|
222
|
+
// styleUrl accepts:
|
|
223
|
+
// - string → single stylesheet
|
|
224
|
+
// - string[] → array of URLs → all fetched & concatenated
|
|
225
|
+
//
|
|
226
|
+
// pages config (shorthand for multi-template + route-param page switching):
|
|
227
|
+
// pages: {
|
|
228
|
+
// dir: 'pages', // relative to component file (or base)
|
|
229
|
+
// param: 'section', // route param name → this.activePage
|
|
230
|
+
// default: 'getting-started', // fallback when param is absent
|
|
231
|
+
// ext: '.html', // file extension (default '.html')
|
|
232
|
+
// items: ['page-a', { id: 'page-b', label: 'Page B' }, ...]
|
|
233
|
+
// }
|
|
234
|
+
// Exposes this.pages (array of {id,label}), this.activePage (current id)
|
|
235
|
+
//
|
|
236
|
+
async _loadExternals() {
|
|
237
|
+
const def = this._def;
|
|
238
|
+
const base = def._base; // auto-detected or explicit
|
|
239
|
+
|
|
240
|
+
// ── Pages config ─────────────────────────────────────────────
|
|
241
|
+
if (def.pages && !def._pagesNormalized) {
|
|
242
|
+
const p = def.pages;
|
|
243
|
+
const ext = p.ext || '.html';
|
|
244
|
+
const dir = _resolveUrl((p.dir || '').replace(/\/+$/, ''), base);
|
|
245
|
+
|
|
246
|
+
// Normalize items → [{id, label}, …]
|
|
247
|
+
def._pages = (p.items || []).map(item => {
|
|
248
|
+
if (typeof item === 'string') return { id: item, label: _titleCase(item) };
|
|
249
|
+
return { id: item.id, label: item.label || _titleCase(item.id) };
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Auto-generate templateUrl object map
|
|
253
|
+
if (!def.templateUrl) {
|
|
254
|
+
def.templateUrl = {};
|
|
255
|
+
for (const { id } of def._pages) {
|
|
256
|
+
def.templateUrl[id] = `${dir}/${id}${ext}`;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
def._pagesNormalized = true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── External templates ──────────────────────────────────────
|
|
264
|
+
if (def.templateUrl && !def._templateLoaded) {
|
|
265
|
+
const tu = def.templateUrl;
|
|
266
|
+
if (typeof tu === 'string') {
|
|
267
|
+
def._externalTemplate = await _fetchResource(_resolveUrl(tu, base));
|
|
268
|
+
} else if (Array.isArray(tu)) {
|
|
269
|
+
const urls = tu.map(u => _resolveUrl(u, base));
|
|
270
|
+
const results = await Promise.all(urls.map(u => _fetchResource(u)));
|
|
271
|
+
def._externalTemplates = {};
|
|
272
|
+
results.forEach((html, i) => { def._externalTemplates[i] = html; });
|
|
273
|
+
} else if (typeof tu === 'object') {
|
|
274
|
+
const entries = Object.entries(tu);
|
|
275
|
+
// Pages config already resolved; plain objects still need resolving
|
|
276
|
+
const results = await Promise.all(
|
|
277
|
+
entries.map(([, url]) => _fetchResource(def._pagesNormalized ? url : _resolveUrl(url, base)))
|
|
278
|
+
);
|
|
279
|
+
def._externalTemplates = {};
|
|
280
|
+
entries.forEach(([key], i) => { def._externalTemplates[key] = results[i]; });
|
|
281
|
+
}
|
|
282
|
+
def._templateLoaded = true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── External styles ─────────────────────────────────────────
|
|
286
|
+
if (def.styleUrl && !def._styleLoaded) {
|
|
287
|
+
const su = def.styleUrl;
|
|
288
|
+
if (typeof su === 'string') {
|
|
289
|
+
def._externalStyles = await _fetchResource(_resolveUrl(su, base));
|
|
290
|
+
} else if (Array.isArray(su)) {
|
|
291
|
+
const urls = su.map(u => _resolveUrl(u, base));
|
|
292
|
+
const results = await Promise.all(urls.map(u => _fetchResource(u)));
|
|
293
|
+
def._externalStyles = results.join('\n');
|
|
294
|
+
}
|
|
295
|
+
def._styleLoaded = true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Render the component
|
|
300
|
+
_render() {
|
|
301
|
+
// If externals haven't loaded yet, trigger async load then re-render
|
|
302
|
+
if ((this._def.templateUrl && !this._def._templateLoaded) ||
|
|
303
|
+
(this._def.styleUrl && !this._def._styleLoaded) ||
|
|
304
|
+
(this._def.pages && !this._def._pagesNormalized)) {
|
|
305
|
+
this._loadExternals().then(() => {
|
|
306
|
+
if (!this._destroyed) this._render();
|
|
307
|
+
});
|
|
308
|
+
return; // Skip this render — will re-render after load
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Expose multi-template map on instance (if available)
|
|
312
|
+
if (this._def._externalTemplates) {
|
|
313
|
+
this.templates = this._def._externalTemplates;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Expose pages metadata and active page (derived from route param)
|
|
317
|
+
if (this._def._pages) {
|
|
318
|
+
this.pages = this._def._pages;
|
|
319
|
+
const pc = this._def.pages;
|
|
320
|
+
this.activePage = (pc.param && this.props.$params?.[pc.param]) || pc.default || this._def._pages[0]?.id || '';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Determine HTML content
|
|
324
|
+
let html;
|
|
325
|
+
if (this._def.render) {
|
|
326
|
+
// Inline render function takes priority
|
|
327
|
+
html = this._def.render.call(this);
|
|
328
|
+
} else if (this._def._externalTemplate) {
|
|
329
|
+
// External template with {{expression}} interpolation
|
|
330
|
+
html = this._def._externalTemplate.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
|
|
331
|
+
try {
|
|
332
|
+
return new Function('state', 'props', '$', `with(state){return ${expr.trim()}}`)(
|
|
333
|
+
this.state.__raw || this.state,
|
|
334
|
+
this.props,
|
|
335
|
+
typeof window !== 'undefined' ? window.$ : undefined
|
|
336
|
+
);
|
|
337
|
+
} catch { return ''; }
|
|
338
|
+
});
|
|
339
|
+
} else {
|
|
340
|
+
html = '';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Combine inline styles + external styles
|
|
344
|
+
const combinedStyles = [
|
|
345
|
+
this._def.styles || '',
|
|
346
|
+
this._def._externalStyles || ''
|
|
347
|
+
].filter(Boolean).join('\n');
|
|
348
|
+
|
|
349
|
+
// Apply scoped styles on first render
|
|
350
|
+
if (!this._mounted && combinedStyles) {
|
|
351
|
+
const scopeAttr = `z-s${this._uid}`;
|
|
352
|
+
this._el.setAttribute(scopeAttr, '');
|
|
353
|
+
const scoped = combinedStyles.replace(/([^{}]+)\{/g, (match, selector) => {
|
|
354
|
+
return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
|
|
355
|
+
});
|
|
356
|
+
const styleEl = document.createElement('style');
|
|
357
|
+
styleEl.textContent = scoped;
|
|
358
|
+
styleEl.setAttribute('data-zq-component', this._def._name || '');
|
|
359
|
+
document.head.appendChild(styleEl);
|
|
360
|
+
this._styleEl = styleEl;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Focus preservation for z-model ────────────────────────────
|
|
364
|
+
// Before replacing innerHTML, save focus state so we can restore
|
|
365
|
+
// cursor position after the DOM is rebuilt.
|
|
366
|
+
let _focusInfo = null;
|
|
367
|
+
const _active = document.activeElement;
|
|
368
|
+
if (_active && this._el.contains(_active)) {
|
|
369
|
+
const modelKey = _active.getAttribute?.('z-model');
|
|
370
|
+
if (modelKey) {
|
|
371
|
+
_focusInfo = {
|
|
372
|
+
key: modelKey,
|
|
373
|
+
start: _active.selectionStart,
|
|
374
|
+
end: _active.selectionEnd,
|
|
375
|
+
dir: _active.selectionDirection,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Update DOM
|
|
381
|
+
this._el.innerHTML = html;
|
|
382
|
+
|
|
383
|
+
// Process directives
|
|
384
|
+
this._bindEvents();
|
|
385
|
+
this._bindRefs();
|
|
386
|
+
this._bindModels();
|
|
387
|
+
|
|
388
|
+
// Restore focus to z-model element after re-render
|
|
389
|
+
if (_focusInfo) {
|
|
390
|
+
const el = this._el.querySelector(`[z-model="${_focusInfo.key}"]`);
|
|
391
|
+
if (el) {
|
|
392
|
+
el.focus();
|
|
393
|
+
try {
|
|
394
|
+
if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
|
|
395
|
+
el.setSelectionRange(_focusInfo.start, _focusInfo.end, _focusInfo.dir);
|
|
396
|
+
}
|
|
397
|
+
} catch (_) { /* some input types don't support setSelectionRange */ }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Mount nested components
|
|
402
|
+
mountAll(this._el);
|
|
403
|
+
|
|
404
|
+
if (!this._mounted) {
|
|
405
|
+
this._mounted = true;
|
|
406
|
+
if (this._def.mounted) this._def.mounted.call(this);
|
|
407
|
+
} else {
|
|
408
|
+
if (this._def.updated) this._def.updated.call(this);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Bind @event="method" handlers via delegation
|
|
413
|
+
_bindEvents() {
|
|
414
|
+
// Clean up old delegated listeners
|
|
415
|
+
this._listeners.forEach(({ event, handler }) => {
|
|
416
|
+
this._el.removeEventListener(event, handler);
|
|
417
|
+
});
|
|
418
|
+
this._listeners = [];
|
|
419
|
+
|
|
420
|
+
// Find all elements with @event attributes
|
|
421
|
+
const allEls = this._el.querySelectorAll('*');
|
|
422
|
+
const eventMap = new Map(); // event → [{ selector, method, modifiers }]
|
|
423
|
+
|
|
424
|
+
allEls.forEach(child => {
|
|
425
|
+
[...child.attributes].forEach(attr => {
|
|
426
|
+
if (!attr.name.startsWith('@')) return;
|
|
427
|
+
|
|
428
|
+
const raw = attr.name.slice(1); // e.g. "click.prevent"
|
|
429
|
+
const parts = raw.split('.');
|
|
430
|
+
const event = parts[0];
|
|
431
|
+
const modifiers = parts.slice(1);
|
|
432
|
+
const methodExpr = attr.value;
|
|
433
|
+
|
|
434
|
+
// Give element a unique selector for delegation
|
|
435
|
+
if (!child.dataset.zqEid) {
|
|
436
|
+
child.dataset.zqEid = String(++_uid);
|
|
437
|
+
}
|
|
438
|
+
const selector = `[data-zq-eid="${child.dataset.zqEid}"]`;
|
|
439
|
+
|
|
440
|
+
if (!eventMap.has(event)) eventMap.set(event, []);
|
|
441
|
+
eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Register delegated listeners on the component root
|
|
446
|
+
for (const [event, bindings] of eventMap) {
|
|
447
|
+
const handler = (e) => {
|
|
448
|
+
for (const { selector, methodExpr, modifiers, el } of bindings) {
|
|
449
|
+
if (!e.target.closest(selector)) continue;
|
|
450
|
+
|
|
451
|
+
// Handle modifiers
|
|
452
|
+
if (modifiers.includes('prevent')) e.preventDefault();
|
|
453
|
+
if (modifiers.includes('stop')) e.stopPropagation();
|
|
454
|
+
|
|
455
|
+
// Parse method expression: "method" or "method(arg1, arg2)"
|
|
456
|
+
const match = methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
|
|
457
|
+
if (match) {
|
|
458
|
+
const methodName = match[1];
|
|
459
|
+
const fn = this[methodName];
|
|
460
|
+
if (typeof fn === 'function') {
|
|
461
|
+
if (match[2] !== undefined) {
|
|
462
|
+
// Parse arguments (supports strings, numbers, state refs)
|
|
463
|
+
const args = match[2].split(',').map(a => {
|
|
464
|
+
a = a.trim();
|
|
465
|
+
if (a === '') return undefined;
|
|
466
|
+
if (a === 'true') return true;
|
|
467
|
+
if (a === 'false') return false;
|
|
468
|
+
if (a === 'null') return null;
|
|
469
|
+
if (/^-?\d+(\.\d+)?$/.test(a)) return Number(a);
|
|
470
|
+
if ((a.startsWith("'") && a.endsWith("'")) || (a.startsWith('"') && a.endsWith('"'))) return a.slice(1, -1);
|
|
471
|
+
// State reference
|
|
472
|
+
if (a.startsWith('state.')) return this.state[a.slice(6)];
|
|
473
|
+
return a;
|
|
474
|
+
}).filter(a => a !== undefined);
|
|
475
|
+
fn(e, ...args);
|
|
476
|
+
} else {
|
|
477
|
+
fn(e);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
this._el.addEventListener(event, handler);
|
|
484
|
+
this._listeners.push({ event, handler });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Bind z-ref="name" → this.refs.name
|
|
489
|
+
_bindRefs() {
|
|
490
|
+
this.refs = {};
|
|
491
|
+
this._el.querySelectorAll('[z-ref]').forEach(el => {
|
|
492
|
+
this.refs[el.getAttribute('z-ref')] = el;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Bind z-model="stateKey" for two-way binding
|
|
497
|
+
//
|
|
498
|
+
// Supported elements: input (text, number, range, checkbox, radio, date, color, …),
|
|
499
|
+
// textarea, select (single & multiple), contenteditable
|
|
500
|
+
// Nested state keys: z-model="user.name" → this.state.user.name
|
|
501
|
+
// Modifiers (boolean attributes on the same element):
|
|
502
|
+
// z-lazy — listen on 'change' instead of 'input' (update on blur / commit)
|
|
503
|
+
// z-trim — trim whitespace before writing to state
|
|
504
|
+
// z-number — force Number() conversion regardless of input type
|
|
505
|
+
//
|
|
506
|
+
// Writes to reactive state so the rest of the UI stays in sync.
|
|
507
|
+
// Focus and cursor position are preserved in _render() via focusInfo.
|
|
508
|
+
//
|
|
509
|
+
_bindModels() {
|
|
510
|
+
this._el.querySelectorAll('[z-model]').forEach(el => {
|
|
511
|
+
const key = el.getAttribute('z-model');
|
|
512
|
+
const tag = el.tagName.toLowerCase();
|
|
513
|
+
const type = (el.type || '').toLowerCase();
|
|
514
|
+
const isEditable = el.hasAttribute('contenteditable');
|
|
515
|
+
|
|
516
|
+
// Modifiers
|
|
517
|
+
const isLazy = el.hasAttribute('z-lazy');
|
|
518
|
+
const isTrim = el.hasAttribute('z-trim');
|
|
519
|
+
const isNum = el.hasAttribute('z-number');
|
|
520
|
+
|
|
521
|
+
// Read current state value (supports dot-path keys)
|
|
522
|
+
const currentVal = _getPath(this.state, key);
|
|
523
|
+
|
|
524
|
+
// ── Set initial DOM value from state ────────────────────────
|
|
525
|
+
if (tag === 'input' && type === 'checkbox') {
|
|
526
|
+
el.checked = !!currentVal;
|
|
527
|
+
} else if (tag === 'input' && type === 'radio') {
|
|
528
|
+
el.checked = el.value === String(currentVal);
|
|
529
|
+
} else if (tag === 'select' && el.multiple) {
|
|
530
|
+
const vals = Array.isArray(currentVal) ? currentVal.map(String) : [];
|
|
531
|
+
[...el.options].forEach(opt => { opt.selected = vals.includes(opt.value); });
|
|
532
|
+
} else if (isEditable) {
|
|
533
|
+
if (el.textContent !== String(currentVal ?? '')) {
|
|
534
|
+
el.textContent = currentVal ?? '';
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
el.value = currentVal ?? '';
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── Determine event type ────────────────────────────────────
|
|
541
|
+
const event = isLazy || tag === 'select' || type === 'checkbox' || type === 'radio'
|
|
542
|
+
? 'change'
|
|
543
|
+
: isEditable ? 'input' : 'input';
|
|
544
|
+
|
|
545
|
+
// ── Handler: read DOM → write to reactive state ─────────────
|
|
546
|
+
const handler = () => {
|
|
547
|
+
let val;
|
|
548
|
+
if (type === 'checkbox') val = el.checked;
|
|
549
|
+
else if (tag === 'select' && el.multiple) val = [...el.selectedOptions].map(o => o.value);
|
|
550
|
+
else if (isEditable) val = el.textContent;
|
|
551
|
+
else val = el.value;
|
|
552
|
+
|
|
553
|
+
// Apply modifiers
|
|
554
|
+
if (isTrim && typeof val === 'string') val = val.trim();
|
|
555
|
+
if (isNum || type === 'number' || type === 'range') val = Number(val);
|
|
556
|
+
|
|
557
|
+
// Write through the reactive proxy (triggers re-render).
|
|
558
|
+
// Focus + cursor are preserved automatically by _render().
|
|
559
|
+
_setPath(this.state, key, val);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
el.addEventListener(event, handler);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Programmatic state update (batch-friendly)
|
|
567
|
+
setState(partial) {
|
|
568
|
+
Object.assign(this.state, partial);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Emit custom event up the DOM
|
|
572
|
+
emit(name, detail) {
|
|
573
|
+
this._el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, cancelable: true }));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Destroy this component
|
|
577
|
+
destroy() {
|
|
578
|
+
if (this._destroyed) return;
|
|
579
|
+
this._destroyed = true;
|
|
580
|
+
if (this._def.destroyed) this._def.destroyed.call(this);
|
|
581
|
+
this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
|
|
582
|
+
this._listeners = [];
|
|
583
|
+
if (this._styleEl) this._styleEl.remove();
|
|
584
|
+
_instances.delete(this._el);
|
|
585
|
+
this._el.innerHTML = '';
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
// Reserved definition keys (not user methods)
|
|
591
|
+
const _reservedKeys = new Set([
|
|
592
|
+
'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
|
|
593
|
+
'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base'
|
|
594
|
+
]);
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
// Public API
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Register a component
|
|
603
|
+
* @param {string} name — tag name (must contain a hyphen, e.g. 'app-counter')
|
|
604
|
+
* @param {object} definition — component definition
|
|
605
|
+
*/
|
|
606
|
+
export function component(name, definition) {
|
|
607
|
+
if (!name.includes('-')) {
|
|
608
|
+
throw new Error(`zQuery: Component name "${name}" must contain a hyphen (Web Component convention)`);
|
|
609
|
+
}
|
|
610
|
+
definition._name = name;
|
|
611
|
+
|
|
612
|
+
// Auto-detect the calling module's URL so that relative templateUrl,
|
|
613
|
+
// styleUrl, and pages.dir paths resolve relative to the component file.
|
|
614
|
+
// An explicit `base` string on the definition overrides auto-detection.
|
|
615
|
+
if (definition.base !== undefined) {
|
|
616
|
+
definition._base = definition.base; // explicit override
|
|
617
|
+
} else {
|
|
618
|
+
definition._base = _detectCallerBase();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
_registry.set(name, definition);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Mount a component into a target element
|
|
626
|
+
* @param {string|Element} target — selector or element to mount into
|
|
627
|
+
* @param {string} componentName — registered component name
|
|
628
|
+
* @param {object} props — props to pass
|
|
629
|
+
* @returns {Component}
|
|
630
|
+
*/
|
|
631
|
+
export function mount(target, componentName, props = {}) {
|
|
632
|
+
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
633
|
+
if (!el) throw new Error(`zQuery: Mount target "${target}" not found`);
|
|
634
|
+
|
|
635
|
+
const def = _registry.get(componentName);
|
|
636
|
+
if (!def) throw new Error(`zQuery: Component "${componentName}" not registered`);
|
|
637
|
+
|
|
638
|
+
// Destroy existing instance
|
|
639
|
+
if (_instances.has(el)) _instances.get(el).destroy();
|
|
640
|
+
|
|
641
|
+
const instance = new Component(el, def, props);
|
|
642
|
+
_instances.set(el, instance);
|
|
643
|
+
instance._render();
|
|
644
|
+
return instance;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Scan a container for custom component tags and auto-mount them
|
|
649
|
+
* @param {Element} root — root element to scan (default: document.body)
|
|
650
|
+
*/
|
|
651
|
+
export function mountAll(root = document.body) {
|
|
652
|
+
for (const [name, def] of _registry) {
|
|
653
|
+
const tags = root.querySelectorAll(name);
|
|
654
|
+
tags.forEach(tag => {
|
|
655
|
+
if (_instances.has(tag)) return; // Already mounted
|
|
656
|
+
|
|
657
|
+
// Extract props from attributes
|
|
658
|
+
const props = {};
|
|
659
|
+
[...tag.attributes].forEach(attr => {
|
|
660
|
+
if (!attr.name.startsWith('@') && !attr.name.startsWith('z-')) {
|
|
661
|
+
// Try JSON parse for objects/arrays
|
|
662
|
+
try { props[attr.name] = JSON.parse(attr.value); }
|
|
663
|
+
catch { props[attr.name] = attr.value; }
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
const instance = new Component(tag, def, props);
|
|
668
|
+
_instances.set(tag, instance);
|
|
669
|
+
instance._render();
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Get the component instance for an element
|
|
676
|
+
* @param {string|Element} target
|
|
677
|
+
* @returns {Component|null}
|
|
678
|
+
*/
|
|
679
|
+
export function getInstance(target) {
|
|
680
|
+
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
681
|
+
return _instances.get(el) || null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Destroy a component at the given target
|
|
686
|
+
* @param {string|Element} target
|
|
687
|
+
*/
|
|
688
|
+
export function destroy(target) {
|
|
689
|
+
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
690
|
+
const inst = _instances.get(el);
|
|
691
|
+
if (inst) inst.destroy();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Get the registry (for debugging)
|
|
696
|
+
*/
|
|
697
|
+
export function getRegistry() {
|
|
698
|
+
return Object.fromEntries(_registry);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// Global stylesheet loader
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
const _globalStyles = new Map(); // url → <link> element
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Load one or more global stylesheets into <head>.
|
|
709
|
+
* Relative URLs resolve against the calling module's directory (auto-detected
|
|
710
|
+
* from the stack trace), just like component styleUrl paths.
|
|
711
|
+
* Returns a remove() handle so the caller can unload if needed.
|
|
712
|
+
*
|
|
713
|
+
* $.style('app.css') // critical by default
|
|
714
|
+
* $.style(['app.css', 'theme.css']) // multiple files
|
|
715
|
+
* $.style('/assets/global.css') // absolute — used as-is
|
|
716
|
+
* $.style('app.css', { critical: false }) // opt out of FOUC prevention
|
|
717
|
+
*
|
|
718
|
+
* Options:
|
|
719
|
+
* critical — (boolean, default true) When true, zQuery injects a tiny
|
|
720
|
+
* inline style that hides the page (`visibility: hidden`) and
|
|
721
|
+
* removes it once the stylesheet has loaded. This prevents
|
|
722
|
+
* FOUC (Flash of Unstyled Content) entirely — no special
|
|
723
|
+
* markup needed in the HTML file. Set to false to load
|
|
724
|
+
* the stylesheet without blocking paint.
|
|
725
|
+
* bg — (string, default '#0d1117') Background color applied while
|
|
726
|
+
* the page is hidden during critical load. Prevents a white
|
|
727
|
+
* flash on dark-themed apps. Only used when critical is true.
|
|
728
|
+
*
|
|
729
|
+
* Duplicate URLs are ignored (idempotent).
|
|
730
|
+
*
|
|
731
|
+
* @param {string|string[]} urls — stylesheet URL(s) to load
|
|
732
|
+
* @param {object} [opts] — options
|
|
733
|
+
* @param {boolean} [opts.critical=true] — hide page until loaded (prevents FOUC)
|
|
734
|
+
* @param {string} [opts.bg] — background color while hidden (default '#0d1117')
|
|
735
|
+
* @returns {{ remove: Function, ready: Promise }} — .remove() to unload, .ready resolves when loaded
|
|
736
|
+
*/
|
|
737
|
+
export function style(urls, opts = {}) {
|
|
738
|
+
const callerBase = _detectCallerBase();
|
|
739
|
+
const list = Array.isArray(urls) ? urls : [urls];
|
|
740
|
+
const elements = [];
|
|
741
|
+
const loadPromises = [];
|
|
742
|
+
|
|
743
|
+
// Critical mode (default: true): inject a tiny inline <style> that hides the
|
|
744
|
+
// page and sets a background color. Fully self-contained — no markup needed
|
|
745
|
+
// in the HTML file. The style is removed once the sheet loads.
|
|
746
|
+
let _criticalStyle = null;
|
|
747
|
+
if (opts.critical !== false) {
|
|
748
|
+
_criticalStyle = document.createElement('style');
|
|
749
|
+
_criticalStyle.setAttribute('data-zq-critical', '');
|
|
750
|
+
_criticalStyle.textContent = `html{visibility:hidden!important;background:${opts.bg || '#0d1117'}}`;
|
|
751
|
+
document.head.insertBefore(_criticalStyle, document.head.firstChild);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
for (let url of list) {
|
|
755
|
+
// Resolve relative paths against the caller's directory first,
|
|
756
|
+
// falling back to <base href> or origin root.
|
|
757
|
+
if (typeof url === 'string' && !url.startsWith('/') && !url.includes(':') && !url.startsWith('//')) {
|
|
758
|
+
url = _resolveUrl(url, callerBase);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (_globalStyles.has(url)) {
|
|
762
|
+
elements.push(_globalStyles.get(url));
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const link = document.createElement('link');
|
|
767
|
+
link.rel = 'stylesheet';
|
|
768
|
+
link.href = url;
|
|
769
|
+
link.setAttribute('data-zq-style', '');
|
|
770
|
+
|
|
771
|
+
const p = new Promise(resolve => {
|
|
772
|
+
link.onload = resolve;
|
|
773
|
+
link.onerror = resolve; // don't block forever on error
|
|
774
|
+
});
|
|
775
|
+
loadPromises.push(p);
|
|
776
|
+
|
|
777
|
+
document.head.appendChild(link);
|
|
778
|
+
_globalStyles.set(url, link);
|
|
779
|
+
elements.push(link);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// When all sheets are loaded, reveal the page if critical mode was used
|
|
783
|
+
const ready = Promise.all(loadPromises).then(() => {
|
|
784
|
+
if (_criticalStyle) {
|
|
785
|
+
_criticalStyle.remove();
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
return {
|
|
790
|
+
ready,
|
|
791
|
+
remove() {
|
|
792
|
+
for (const el of elements) {
|
|
793
|
+
el.remove();
|
|
794
|
+
for (const [k, v] of _globalStyles) {
|
|
795
|
+
if (v === el) { _globalStyles.delete(k); break; }
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
}
|