zero-query 0.2.0 → 0.2.3
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/README.md +64 -123
- package/cli.js +186 -114
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +2605 -0
- package/dist/zquery.min.js +17 -0
- package/index.js +1 -0
- package/package.json +6 -4
- package/src/router.js +24 -1
package/dist/zquery.js
ADDED
|
@@ -0,0 +1,2605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zQuery (zeroQuery) v0.2.3
|
|
3
|
+
* Lightweight Frontend Library
|
|
4
|
+
* https://github.com/tonywied17/zero-query
|
|
5
|
+
* (c) 2026 Anthony Wiedman — MIT License
|
|
6
|
+
*/
|
|
7
|
+
(function(global) {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
// --- src/reactive.js —————————————————————————————————————————————
|
|
11
|
+
/**
|
|
12
|
+
* zQuery Reactive — Proxy-based deep reactivity system
|
|
13
|
+
*
|
|
14
|
+
* Creates observable objects that trigger callbacks on mutation.
|
|
15
|
+
* Used internally by components and store for auto-updates.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Deep reactive proxy
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
function reactive(target, onChange, _path = '') {
|
|
22
|
+
if (typeof target !== 'object' || target === null) return target;
|
|
23
|
+
|
|
24
|
+
const proxyCache = new WeakMap();
|
|
25
|
+
|
|
26
|
+
const handler = {
|
|
27
|
+
get(obj, key) {
|
|
28
|
+
if (key === '__isReactive') return true;
|
|
29
|
+
if (key === '__raw') return obj;
|
|
30
|
+
|
|
31
|
+
const value = obj[key];
|
|
32
|
+
if (typeof value === 'object' && value !== null) {
|
|
33
|
+
// Return cached proxy or create new one
|
|
34
|
+
if (proxyCache.has(value)) return proxyCache.get(value);
|
|
35
|
+
const childProxy = new Proxy(value, handler);
|
|
36
|
+
proxyCache.set(value, childProxy);
|
|
37
|
+
return childProxy;
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
set(obj, key, value) {
|
|
43
|
+
const old = obj[key];
|
|
44
|
+
if (old === value) return true;
|
|
45
|
+
obj[key] = value;
|
|
46
|
+
onChange(key, value, old);
|
|
47
|
+
return true;
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
deleteProperty(obj, key) {
|
|
51
|
+
const old = obj[key];
|
|
52
|
+
delete obj[key];
|
|
53
|
+
onChange(key, undefined, old);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return new Proxy(target, handler);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Signal — lightweight reactive primitive (inspired by Solid/Preact signals)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
class Signal {
|
|
66
|
+
constructor(value) {
|
|
67
|
+
this._value = value;
|
|
68
|
+
this._subscribers = new Set();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get value() {
|
|
72
|
+
// Track dependency if there's an active effect
|
|
73
|
+
if (Signal._activeEffect) {
|
|
74
|
+
this._subscribers.add(Signal._activeEffect);
|
|
75
|
+
}
|
|
76
|
+
return this._value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
set value(newVal) {
|
|
80
|
+
if (this._value === newVal) return;
|
|
81
|
+
this._value = newVal;
|
|
82
|
+
this._notify();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
peek() { return this._value; }
|
|
86
|
+
|
|
87
|
+
_notify() {
|
|
88
|
+
this._subscribers.forEach(fn => fn());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
subscribe(fn) {
|
|
92
|
+
this._subscribers.add(fn);
|
|
93
|
+
return () => this._subscribers.delete(fn);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
toString() { return String(this._value); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Active effect tracking
|
|
100
|
+
Signal._activeEffect = null;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Create a signal
|
|
104
|
+
* @param {*} initial — initial value
|
|
105
|
+
* @returns {Signal}
|
|
106
|
+
*/
|
|
107
|
+
function signal(initial) {
|
|
108
|
+
return new Signal(initial);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create a computed signal (derived from other signals)
|
|
113
|
+
* @param {Function} fn — computation function
|
|
114
|
+
* @returns {Signal}
|
|
115
|
+
*/
|
|
116
|
+
function computed(fn) {
|
|
117
|
+
const s = new Signal(undefined);
|
|
118
|
+
effect(() => { s._value = fn(); s._notify(); });
|
|
119
|
+
return s;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create a side-effect that auto-tracks signal dependencies
|
|
124
|
+
* @param {Function} fn — effect function
|
|
125
|
+
* @returns {Function} — dispose function
|
|
126
|
+
*/
|
|
127
|
+
function effect(fn) {
|
|
128
|
+
const execute = () => {
|
|
129
|
+
Signal._activeEffect = execute;
|
|
130
|
+
try { fn(); }
|
|
131
|
+
finally { Signal._activeEffect = null; }
|
|
132
|
+
};
|
|
133
|
+
execute();
|
|
134
|
+
return () => { /* Signals will hold weak refs if needed */ };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- src/core.js —————————————————————————————————————————————————
|
|
138
|
+
/**
|
|
139
|
+
* zQuery Core — Selector engine & chainable DOM collection
|
|
140
|
+
*
|
|
141
|
+
* Extends the quick-ref pattern (Id, Class, Classes, Children)
|
|
142
|
+
* into a full jQuery-like chainable wrapper with modern APIs.
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// ZQueryCollection — wraps an array of elements with chainable methods
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
class ZQueryCollection {
|
|
149
|
+
constructor(elements) {
|
|
150
|
+
this.elements = Array.isArray(elements) ? elements : [elements];
|
|
151
|
+
this.length = this.elements.length;
|
|
152
|
+
this.elements.forEach((el, i) => { this[i] = el; });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Iteration -----------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
each(fn) {
|
|
158
|
+
this.elements.forEach((el, i) => fn.call(el, i, el));
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
map(fn) {
|
|
163
|
+
return this.elements.map((el, i) => fn.call(el, i, el));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
first() { return this.elements[0] || null; }
|
|
167
|
+
last() { return this.elements[this.length - 1] || null; }
|
|
168
|
+
eq(i) { return new ZQueryCollection(this.elements[i] ? [this.elements[i]] : []); }
|
|
169
|
+
toArray(){ return [...this.elements]; }
|
|
170
|
+
|
|
171
|
+
[Symbol.iterator]() { return this.elements[Symbol.iterator](); }
|
|
172
|
+
|
|
173
|
+
// --- Traversal -----------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
find(selector) {
|
|
176
|
+
const found = [];
|
|
177
|
+
this.elements.forEach(el => found.push(...el.querySelectorAll(selector)));
|
|
178
|
+
return new ZQueryCollection(found);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
parent() {
|
|
182
|
+
const parents = [...new Set(this.elements.map(el => el.parentElement).filter(Boolean))];
|
|
183
|
+
return new ZQueryCollection(parents);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
closest(selector) {
|
|
187
|
+
return new ZQueryCollection(
|
|
188
|
+
this.elements.map(el => el.closest(selector)).filter(Boolean)
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
children(selector) {
|
|
193
|
+
const kids = [];
|
|
194
|
+
this.elements.forEach(el => {
|
|
195
|
+
kids.push(...(selector
|
|
196
|
+
? el.querySelectorAll(`:scope > ${selector}`)
|
|
197
|
+
: el.children));
|
|
198
|
+
});
|
|
199
|
+
return new ZQueryCollection([...kids]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
siblings() {
|
|
203
|
+
const sibs = [];
|
|
204
|
+
this.elements.forEach(el => {
|
|
205
|
+
sibs.push(...[...el.parentElement.children].filter(c => c !== el));
|
|
206
|
+
});
|
|
207
|
+
return new ZQueryCollection(sibs);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
next() { return new ZQueryCollection(this.elements.map(el => el.nextElementSibling).filter(Boolean)); }
|
|
211
|
+
prev() { return new ZQueryCollection(this.elements.map(el => el.previousElementSibling).filter(Boolean)); }
|
|
212
|
+
|
|
213
|
+
filter(selector) {
|
|
214
|
+
if (typeof selector === 'function') {
|
|
215
|
+
return new ZQueryCollection(this.elements.filter(selector));
|
|
216
|
+
}
|
|
217
|
+
return new ZQueryCollection(this.elements.filter(el => el.matches(selector)));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
not(selector) {
|
|
221
|
+
if (typeof selector === 'function') {
|
|
222
|
+
return new ZQueryCollection(this.elements.filter((el, i) => !selector.call(el, i, el)));
|
|
223
|
+
}
|
|
224
|
+
return new ZQueryCollection(this.elements.filter(el => !el.matches(selector)));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
has(selector) {
|
|
228
|
+
return new ZQueryCollection(this.elements.filter(el => el.querySelector(selector)));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- Classes -------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
addClass(...names) {
|
|
234
|
+
const classes = names.flatMap(n => n.split(/\s+/));
|
|
235
|
+
return this.each((_, el) => el.classList.add(...classes));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
removeClass(...names) {
|
|
239
|
+
const classes = names.flatMap(n => n.split(/\s+/));
|
|
240
|
+
return this.each((_, el) => el.classList.remove(...classes));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
toggleClass(name, force) {
|
|
244
|
+
return this.each((_, el) => el.classList.toggle(name, force));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
hasClass(name) {
|
|
248
|
+
return this.first()?.classList.contains(name) || false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Attributes ----------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
attr(name, value) {
|
|
254
|
+
if (value === undefined) return this.first()?.getAttribute(name);
|
|
255
|
+
return this.each((_, el) => el.setAttribute(name, value));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
removeAttr(name) {
|
|
259
|
+
return this.each((_, el) => el.removeAttribute(name));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
prop(name, value) {
|
|
263
|
+
if (value === undefined) return this.first()?.[name];
|
|
264
|
+
return this.each((_, el) => { el[name] = value; });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
data(key, value) {
|
|
268
|
+
if (value === undefined) {
|
|
269
|
+
if (key === undefined) return this.first()?.dataset;
|
|
270
|
+
const raw = this.first()?.dataset[key];
|
|
271
|
+
try { return JSON.parse(raw); } catch { return raw; }
|
|
272
|
+
}
|
|
273
|
+
return this.each((_, el) => { el.dataset[key] = typeof value === 'object' ? JSON.stringify(value) : value; });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// --- CSS / Dimensions ----------------------------------------------------
|
|
277
|
+
|
|
278
|
+
css(props) {
|
|
279
|
+
if (typeof props === 'string') {
|
|
280
|
+
return getComputedStyle(this.first())[props];
|
|
281
|
+
}
|
|
282
|
+
return this.each((_, el) => Object.assign(el.style, props));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
width() { return this.first()?.getBoundingClientRect().width; }
|
|
286
|
+
height() { return this.first()?.getBoundingClientRect().height; }
|
|
287
|
+
|
|
288
|
+
offset() {
|
|
289
|
+
const r = this.first()?.getBoundingClientRect();
|
|
290
|
+
return r ? { top: r.top + window.scrollY, left: r.left + window.scrollX, width: r.width, height: r.height } : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
position() {
|
|
294
|
+
const el = this.first();
|
|
295
|
+
return el ? { top: el.offsetTop, left: el.offsetLeft } : null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- Content -------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
html(content) {
|
|
301
|
+
if (content === undefined) return this.first()?.innerHTML;
|
|
302
|
+
return this.each((_, el) => { el.innerHTML = content; });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
text(content) {
|
|
306
|
+
if (content === undefined) return this.first()?.textContent;
|
|
307
|
+
return this.each((_, el) => { el.textContent = content; });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
val(value) {
|
|
311
|
+
if (value === undefined) return this.first()?.value;
|
|
312
|
+
return this.each((_, el) => { el.value = value; });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- DOM Manipulation ----------------------------------------------------
|
|
316
|
+
|
|
317
|
+
append(content) {
|
|
318
|
+
return this.each((_, el) => {
|
|
319
|
+
if (typeof content === 'string') el.insertAdjacentHTML('beforeend', content);
|
|
320
|
+
else if (content instanceof ZQueryCollection) content.each((__, c) => el.appendChild(c));
|
|
321
|
+
else if (content instanceof Node) el.appendChild(content);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
prepend(content) {
|
|
326
|
+
return this.each((_, el) => {
|
|
327
|
+
if (typeof content === 'string') el.insertAdjacentHTML('afterbegin', content);
|
|
328
|
+
else if (content instanceof Node) el.insertBefore(content, el.firstChild);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
after(content) {
|
|
333
|
+
return this.each((_, el) => {
|
|
334
|
+
if (typeof content === 'string') el.insertAdjacentHTML('afterend', content);
|
|
335
|
+
else if (content instanceof Node) el.parentNode.insertBefore(content, el.nextSibling);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
before(content) {
|
|
340
|
+
return this.each((_, el) => {
|
|
341
|
+
if (typeof content === 'string') el.insertAdjacentHTML('beforebegin', content);
|
|
342
|
+
else if (content instanceof Node) el.parentNode.insertBefore(content, el);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
wrap(wrapper) {
|
|
347
|
+
return this.each((_, el) => {
|
|
348
|
+
const w = typeof wrapper === 'string' ? createFragment(wrapper).firstElementChild : wrapper.cloneNode(true);
|
|
349
|
+
el.parentNode.insertBefore(w, el);
|
|
350
|
+
w.appendChild(el);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
remove() {
|
|
355
|
+
return this.each((_, el) => el.remove());
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
empty() {
|
|
359
|
+
return this.each((_, el) => { el.innerHTML = ''; });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
clone(deep = true) {
|
|
363
|
+
return new ZQueryCollection(this.elements.map(el => el.cloneNode(deep)));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
replaceWith(content) {
|
|
367
|
+
return this.each((_, el) => {
|
|
368
|
+
if (typeof content === 'string') {
|
|
369
|
+
el.insertAdjacentHTML('afterend', content);
|
|
370
|
+
el.remove();
|
|
371
|
+
} else if (content instanceof Node) {
|
|
372
|
+
el.parentNode.replaceChild(content, el);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// --- Visibility ----------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
show(display = '') {
|
|
380
|
+
return this.each((_, el) => { el.style.display = display; });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
hide() {
|
|
384
|
+
return this.each((_, el) => { el.style.display = 'none'; });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
toggle(display = '') {
|
|
388
|
+
return this.each((_, el) => {
|
|
389
|
+
el.style.display = (el.style.display === 'none' || getComputedStyle(el).display === 'none') ? display : 'none';
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// --- Events --------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
on(event, selectorOrHandler, handler) {
|
|
396
|
+
// Support multiple events: "click mouseenter"
|
|
397
|
+
const events = event.split(/\s+/);
|
|
398
|
+
return this.each((_, el) => {
|
|
399
|
+
events.forEach(evt => {
|
|
400
|
+
if (typeof selectorOrHandler === 'function') {
|
|
401
|
+
el.addEventListener(evt, selectorOrHandler);
|
|
402
|
+
} else {
|
|
403
|
+
// Delegated event
|
|
404
|
+
el.addEventListener(evt, (e) => {
|
|
405
|
+
const target = e.target.closest(selectorOrHandler);
|
|
406
|
+
if (target && el.contains(target)) handler.call(target, e);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
off(event, handler) {
|
|
414
|
+
const events = event.split(/\s+/);
|
|
415
|
+
return this.each((_, el) => {
|
|
416
|
+
events.forEach(evt => el.removeEventListener(evt, handler));
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
one(event, handler) {
|
|
421
|
+
return this.each((_, el) => {
|
|
422
|
+
el.addEventListener(event, handler, { once: true });
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
trigger(event, detail) {
|
|
427
|
+
return this.each((_, el) => {
|
|
428
|
+
el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Convenience event shorthands
|
|
433
|
+
click(fn) { return fn ? this.on('click', fn) : this.trigger('click'); }
|
|
434
|
+
submit(fn) { return fn ? this.on('submit', fn) : this.trigger('submit'); }
|
|
435
|
+
focus() { this.first()?.focus(); return this; }
|
|
436
|
+
blur() { this.first()?.blur(); return this; }
|
|
437
|
+
|
|
438
|
+
// --- Animation -----------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
animate(props, duration = 300, easing = 'ease') {
|
|
441
|
+
return new Promise(resolve => {
|
|
442
|
+
const count = { done: 0 };
|
|
443
|
+
this.each((_, el) => {
|
|
444
|
+
el.style.transition = `all ${duration}ms ${easing}`;
|
|
445
|
+
requestAnimationFrame(() => {
|
|
446
|
+
Object.assign(el.style, props);
|
|
447
|
+
const onEnd = () => {
|
|
448
|
+
el.removeEventListener('transitionend', onEnd);
|
|
449
|
+
el.style.transition = '';
|
|
450
|
+
if (++count.done >= this.length) resolve(this);
|
|
451
|
+
};
|
|
452
|
+
el.addEventListener('transitionend', onEnd);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
// Fallback in case transitionend doesn't fire
|
|
456
|
+
setTimeout(() => resolve(this), duration + 50);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
fadeIn(duration = 300) {
|
|
461
|
+
return this.css({ opacity: '0', display: '' }).animate({ opacity: '1' }, duration);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
fadeOut(duration = 300) {
|
|
465
|
+
return this.animate({ opacity: '0' }, duration).then(col => col.hide());
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
slideToggle(duration = 300) {
|
|
469
|
+
return this.each((_, el) => {
|
|
470
|
+
if (el.style.display === 'none' || getComputedStyle(el).display === 'none') {
|
|
471
|
+
el.style.display = '';
|
|
472
|
+
el.style.overflow = 'hidden';
|
|
473
|
+
const h = el.scrollHeight + 'px';
|
|
474
|
+
el.style.maxHeight = '0';
|
|
475
|
+
el.style.transition = `max-height ${duration}ms ease`;
|
|
476
|
+
requestAnimationFrame(() => { el.style.maxHeight = h; });
|
|
477
|
+
setTimeout(() => { el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
|
|
478
|
+
} else {
|
|
479
|
+
el.style.overflow = 'hidden';
|
|
480
|
+
el.style.maxHeight = el.scrollHeight + 'px';
|
|
481
|
+
el.style.transition = `max-height ${duration}ms ease`;
|
|
482
|
+
requestAnimationFrame(() => { el.style.maxHeight = '0'; });
|
|
483
|
+
setTimeout(() => { el.style.display = 'none'; el.style.maxHeight = ''; el.style.overflow = ''; el.style.transition = ''; }, duration);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// --- Form helpers --------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
serialize() {
|
|
491
|
+
const form = this.first();
|
|
492
|
+
if (!form || form.tagName !== 'FORM') return '';
|
|
493
|
+
return new URLSearchParams(new FormData(form)).toString();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
serializeObject() {
|
|
497
|
+
const form = this.first();
|
|
498
|
+
if (!form || form.tagName !== 'FORM') return {};
|
|
499
|
+
const obj = {};
|
|
500
|
+
new FormData(form).forEach((v, k) => {
|
|
501
|
+
if (obj[k] !== undefined) {
|
|
502
|
+
if (!Array.isArray(obj[k])) obj[k] = [obj[k]];
|
|
503
|
+
obj[k].push(v);
|
|
504
|
+
} else {
|
|
505
|
+
obj[k] = v;
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
return obj;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// Helper — create document fragment from HTML string
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
function createFragment(html) {
|
|
517
|
+
const tpl = document.createElement('template');
|
|
518
|
+
tpl.innerHTML = html.trim();
|
|
519
|
+
return tpl.content;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// $() — main selector / creator function (returns single element for CSS selectors)
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
function query(selector, context) {
|
|
527
|
+
// null / undefined
|
|
528
|
+
if (!selector) return null;
|
|
529
|
+
|
|
530
|
+
// Already a collection — return first element
|
|
531
|
+
if (selector instanceof ZQueryCollection) return selector.first();
|
|
532
|
+
|
|
533
|
+
// DOM element or Window — return as-is
|
|
534
|
+
if (selector instanceof Node || selector === window) {
|
|
535
|
+
return selector;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// NodeList / HTMLCollection / Array — return first element
|
|
539
|
+
if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
|
|
540
|
+
const arr = Array.from(selector);
|
|
541
|
+
return arr[0] || null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// HTML string → create elements, return first
|
|
545
|
+
if (typeof selector === 'string' && selector.trim().startsWith('<')) {
|
|
546
|
+
const fragment = createFragment(selector);
|
|
547
|
+
const els = [...fragment.childNodes].filter(n => n.nodeType === 1);
|
|
548
|
+
return els[0] || null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// CSS selector string → querySelector (single element)
|
|
552
|
+
if (typeof selector === 'string') {
|
|
553
|
+
const root = context
|
|
554
|
+
? (typeof context === 'string' ? document.querySelector(context) : context)
|
|
555
|
+
: document;
|
|
556
|
+
return root.querySelector(selector);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// $.all() — collection selector (returns ZQueryCollection for CSS selectors)
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
function queryAll(selector, context) {
|
|
567
|
+
// null / undefined
|
|
568
|
+
if (!selector) return new ZQueryCollection([]);
|
|
569
|
+
|
|
570
|
+
// Already a collection
|
|
571
|
+
if (selector instanceof ZQueryCollection) return selector;
|
|
572
|
+
|
|
573
|
+
// DOM element or Window
|
|
574
|
+
if (selector instanceof Node || selector === window) {
|
|
575
|
+
return new ZQueryCollection([selector]);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// NodeList / HTMLCollection / Array
|
|
579
|
+
if (selector instanceof NodeList || selector instanceof HTMLCollection || Array.isArray(selector)) {
|
|
580
|
+
return new ZQueryCollection(Array.from(selector));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// HTML string → create elements
|
|
584
|
+
if (typeof selector === 'string' && selector.trim().startsWith('<')) {
|
|
585
|
+
const fragment = createFragment(selector);
|
|
586
|
+
return new ZQueryCollection([...fragment.childNodes].filter(n => n.nodeType === 1));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// CSS selector string → querySelectorAll (collection)
|
|
590
|
+
if (typeof selector === 'string') {
|
|
591
|
+
const root = context
|
|
592
|
+
? (typeof context === 'string' ? document.querySelector(context) : context)
|
|
593
|
+
: document;
|
|
594
|
+
return new ZQueryCollection([...root.querySelectorAll(selector)]);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return new ZQueryCollection([]);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
// Quick-ref shortcuts, on $ namespace)
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
query.id = (id) => document.getElementById(id);
|
|
605
|
+
query.class = (name) => document.querySelector(`.${name}`);
|
|
606
|
+
query.classes = (name) => Array.from(document.getElementsByClassName(name));
|
|
607
|
+
query.tag = (name) => Array.from(document.getElementsByTagName(name));
|
|
608
|
+
query.children = (parentId) => {
|
|
609
|
+
const p = document.getElementById(parentId);
|
|
610
|
+
return p ? Array.from(p.children) : [];
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
// Create element shorthand
|
|
614
|
+
query.create = (tag, attrs = {}, ...children) => {
|
|
615
|
+
const el = document.createElement(tag);
|
|
616
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
617
|
+
if (k === 'class') el.className = v;
|
|
618
|
+
else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
|
|
619
|
+
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
|
|
620
|
+
else if (k === 'data' && typeof v === 'object') Object.entries(v).forEach(([dk, dv]) => { el.dataset[dk] = dv; });
|
|
621
|
+
else el.setAttribute(k, v);
|
|
622
|
+
}
|
|
623
|
+
children.flat().forEach(child => {
|
|
624
|
+
if (typeof child === 'string') el.appendChild(document.createTextNode(child));
|
|
625
|
+
else if (child instanceof Node) el.appendChild(child);
|
|
626
|
+
});
|
|
627
|
+
return el;
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// DOM ready
|
|
631
|
+
query.ready = (fn) => {
|
|
632
|
+
if (document.readyState !== 'loading') fn();
|
|
633
|
+
else document.addEventListener('DOMContentLoaded', fn);
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// Global event delegation
|
|
637
|
+
query.on = (event, selector, handler) => {
|
|
638
|
+
document.addEventListener(event, (e) => {
|
|
639
|
+
const target = e.target.closest(selector);
|
|
640
|
+
if (target) handler.call(target, e);
|
|
641
|
+
});
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
// Extend collection prototype (like $.fn in jQuery)
|
|
645
|
+
query.fn = ZQueryCollection.prototype;
|
|
646
|
+
|
|
647
|
+
// --- src/component.js ————————————————————————————————————————————
|
|
648
|
+
/**
|
|
649
|
+
* zQuery Component — Lightweight reactive component system
|
|
650
|
+
*
|
|
651
|
+
* Declarative components using template literals (no JSX, no build step).
|
|
652
|
+
* Proxy-based state triggers targeted re-renders via event delegation.
|
|
653
|
+
*
|
|
654
|
+
* Features:
|
|
655
|
+
* - Reactive state (auto re-render on mutation)
|
|
656
|
+
* - Template literals with full JS expression power
|
|
657
|
+
* - @event="method" syntax for event binding (delegated)
|
|
658
|
+
* - z-ref="name" for element references
|
|
659
|
+
* - z-model="stateKey" for two-way binding
|
|
660
|
+
* - Lifecycle hooks: init, mounted, updated, destroyed
|
|
661
|
+
* - Props passed via attributes
|
|
662
|
+
* - Scoped styles (inline or via styleUrl)
|
|
663
|
+
* - External templates via templateUrl (with {{expression}} interpolation)
|
|
664
|
+
* - External styles via styleUrl (fetched & scoped automatically)
|
|
665
|
+
* - Relative path resolution — templateUrl, styleUrl, and pages.dir
|
|
666
|
+
* resolve relative to the component file automatically
|
|
667
|
+
*/
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
// Component registry & external resource cache
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
const _registry = new Map(); // name → definition
|
|
674
|
+
const _instances = new Map(); // element → instance
|
|
675
|
+
const _resourceCache = new Map(); // url → Promise<string>
|
|
676
|
+
|
|
677
|
+
// Unique ID counter
|
|
678
|
+
let _uid = 0;
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Fetch and cache a text resource (HTML template or CSS file).
|
|
682
|
+
* @param {string} url — URL to fetch
|
|
683
|
+
* @returns {Promise<string>}
|
|
684
|
+
*/
|
|
685
|
+
function _fetchResource(url) {
|
|
686
|
+
if (_resourceCache.has(url)) return _resourceCache.get(url);
|
|
687
|
+
|
|
688
|
+
// Check inline resource map (populated by CLI bundler for file:// support).
|
|
689
|
+
// Keys are relative paths; match against the URL suffix.
|
|
690
|
+
if (typeof window !== 'undefined' && window.__zqInline) {
|
|
691
|
+
for (const [path, content] of Object.entries(window.__zqInline)) {
|
|
692
|
+
if (url === path || url.endsWith('/' + path) || url.endsWith('\\' + path)) {
|
|
693
|
+
const resolved = Promise.resolve(content);
|
|
694
|
+
_resourceCache.set(url, resolved);
|
|
695
|
+
return resolved;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Resolve relative URLs against <base href> or origin root.
|
|
701
|
+
// This prevents SPA route paths (e.g. /docs/advanced) from
|
|
702
|
+
// breaking relative resource URLs like 'scripts/components/foo.css'.
|
|
703
|
+
let resolvedUrl = url;
|
|
704
|
+
if (typeof url === 'string' && !url.startsWith('/') && !url.includes(':') && !url.startsWith('//')) {
|
|
705
|
+
try {
|
|
706
|
+
const baseEl = document.querySelector('base');
|
|
707
|
+
const root = baseEl ? baseEl.href : (window.location.origin + '/');
|
|
708
|
+
resolvedUrl = new URL(url, root).href;
|
|
709
|
+
} catch { /* keep original */ }
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const promise = fetch(resolvedUrl).then(res => {
|
|
713
|
+
if (!res.ok) throw new Error(`zQuery: Failed to load resource "${url}" (${res.status})`);
|
|
714
|
+
return res.text();
|
|
715
|
+
});
|
|
716
|
+
_resourceCache.set(url, promise);
|
|
717
|
+
return promise;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Convert a kebab-case id to Title Case.
|
|
722
|
+
* 'getting-started' → 'Getting Started'
|
|
723
|
+
* @param {string} id
|
|
724
|
+
* @returns {string}
|
|
725
|
+
*/
|
|
726
|
+
function _titleCase(id) {
|
|
727
|
+
return id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Resolve a relative URL against a base.
|
|
732
|
+
*
|
|
733
|
+
* - If `base` is an absolute URL (http/https/file), resolve directly.
|
|
734
|
+
* - If `base` is a relative path string, resolve it against the page root
|
|
735
|
+
* (or <base href>) first, then resolve `url` against that.
|
|
736
|
+
* - If `base` is falsy, return `url` unchanged — _fetchResource's own
|
|
737
|
+
* fallback (page root / <base href>) handles it.
|
|
738
|
+
*
|
|
739
|
+
* @param {string} url — URL or relative path to resolve
|
|
740
|
+
* @param {string} [base] — auto-detected caller URL or explicit base path
|
|
741
|
+
* @returns {string}
|
|
742
|
+
*/
|
|
743
|
+
function _resolveUrl(url, base) {
|
|
744
|
+
if (!base || !url || typeof url !== 'string') return url;
|
|
745
|
+
// Already absolute — nothing to do
|
|
746
|
+
if (url.startsWith('/') || url.includes('://') || url.startsWith('//')) return url;
|
|
747
|
+
try {
|
|
748
|
+
if (base.includes('://')) {
|
|
749
|
+
// Absolute base (auto-detected module URL)
|
|
750
|
+
return new URL(url, base).href;
|
|
751
|
+
}
|
|
752
|
+
// Relative base string — resolve against page root first
|
|
753
|
+
const baseEl = document.querySelector('base');
|
|
754
|
+
const root = baseEl ? baseEl.href : (window.location.origin + '/');
|
|
755
|
+
const absBase = new URL(base.endsWith('/') ? base : base + '/', root).href;
|
|
756
|
+
return new URL(url, absBase).href;
|
|
757
|
+
} catch {
|
|
758
|
+
return url;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Capture the library's own script URL at load time for reliable filtering.
|
|
763
|
+
// This handles cases where the bundle is renamed (e.g., 'vendor.js').
|
|
764
|
+
let _ownScriptUrl;
|
|
765
|
+
try {
|
|
766
|
+
if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
|
|
767
|
+
_ownScriptUrl = document.currentScript.src.replace(/[?#].*$/, '');
|
|
768
|
+
}
|
|
769
|
+
} catch { /* ignored */ }
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Detect the URL of the module that called $.component().
|
|
773
|
+
* Parses Error().stack to find the first frame outside the zQuery bundle.
|
|
774
|
+
* Returns the directory URL (with trailing slash) or undefined.
|
|
775
|
+
* @returns {string|undefined}
|
|
776
|
+
*/
|
|
777
|
+
function _detectCallerBase() {
|
|
778
|
+
try {
|
|
779
|
+
const stack = new Error().stack || '';
|
|
780
|
+
const urls = stack.match(/(?:https?|file):\/\/[^\s\)]+/g) || [];
|
|
781
|
+
for (const raw of urls) {
|
|
782
|
+
// Strip line:col suffixes e.g. ":3:5" or ":12:1"
|
|
783
|
+
const url = raw.replace(/:\d+:\d+$/, '').replace(/:\d+$/, '');
|
|
784
|
+
// Skip the zQuery library itself — by filename pattern and captured URL
|
|
785
|
+
if (/zquery(\.min)?\.js$/i.test(url)) continue;
|
|
786
|
+
if (_ownScriptUrl && url.replace(/[?#].*$/, '') === _ownScriptUrl) continue;
|
|
787
|
+
// Return directory (strip filename, keep trailing slash)
|
|
788
|
+
return url.replace(/\/[^/]*$/, '/');
|
|
789
|
+
}
|
|
790
|
+
} catch { /* stack parsing unsupported — fall back silently */ }
|
|
791
|
+
return undefined;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Get a value from a nested object by dot-path.
|
|
796
|
+
* _getPath(obj, 'user.name') → obj.user.name
|
|
797
|
+
* @param {object} obj
|
|
798
|
+
* @param {string} path
|
|
799
|
+
* @returns {*}
|
|
800
|
+
*/
|
|
801
|
+
function _getPath(obj, path) {
|
|
802
|
+
return path.split('.').reduce((o, k) => o?.[k], obj);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Set a value on a nested object by dot-path, walking through proxy layers.
|
|
807
|
+
* _setPath(proxy, 'user.name', 'Tony') → proxy.user.name = 'Tony'
|
|
808
|
+
* @param {object} obj
|
|
809
|
+
* @param {string} path
|
|
810
|
+
* @param {*} value
|
|
811
|
+
*/
|
|
812
|
+
function _setPath(obj, path, value) {
|
|
813
|
+
const keys = path.split('.');
|
|
814
|
+
const last = keys.pop();
|
|
815
|
+
const target = keys.reduce((o, k) => (o && typeof o === 'object') ? o[k] : undefined, obj);
|
|
816
|
+
if (target && typeof target === 'object') target[last] = value;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
// ---------------------------------------------------------------------------
|
|
821
|
+
// Component class
|
|
822
|
+
// ---------------------------------------------------------------------------
|
|
823
|
+
class Component {
|
|
824
|
+
constructor(el, definition, props = {}) {
|
|
825
|
+
this._uid = ++_uid;
|
|
826
|
+
this._el = el;
|
|
827
|
+
this._def = definition;
|
|
828
|
+
this._mounted = false;
|
|
829
|
+
this._destroyed = false;
|
|
830
|
+
this._updateQueued = false;
|
|
831
|
+
this._listeners = [];
|
|
832
|
+
|
|
833
|
+
// Refs map
|
|
834
|
+
this.refs = {};
|
|
835
|
+
|
|
836
|
+
// Props (read-only from parent)
|
|
837
|
+
this.props = Object.freeze({ ...props });
|
|
838
|
+
|
|
839
|
+
// Reactive state
|
|
840
|
+
const initialState = typeof definition.state === 'function'
|
|
841
|
+
? definition.state()
|
|
842
|
+
: { ...(definition.state || {}) };
|
|
843
|
+
|
|
844
|
+
this.state = reactive(initialState, () => {
|
|
845
|
+
if (!this._destroyed) this._scheduleUpdate();
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// Bind all user methods to this instance
|
|
849
|
+
for (const [key, val] of Object.entries(definition)) {
|
|
850
|
+
if (typeof val === 'function' && !_reservedKeys.has(key)) {
|
|
851
|
+
this[key] = val.bind(this);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Init lifecycle
|
|
856
|
+
if (definition.init) definition.init.call(this);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Schedule a batched DOM update (microtask)
|
|
860
|
+
_scheduleUpdate() {
|
|
861
|
+
if (this._updateQueued) return;
|
|
862
|
+
this._updateQueued = true;
|
|
863
|
+
queueMicrotask(() => {
|
|
864
|
+
this._updateQueued = false;
|
|
865
|
+
if (!this._destroyed) this._render();
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Load external templateUrl / styleUrl if specified (once per definition)
|
|
870
|
+
//
|
|
871
|
+
// Relative paths are resolved automatically against the component file's
|
|
872
|
+
// own directory (auto-detected at registration time). You can override
|
|
873
|
+
// this with `base: 'some/path/'` on the definition.
|
|
874
|
+
//
|
|
875
|
+
// templateUrl accepts:
|
|
876
|
+
// - string → single template (used with {{expr}} interpolation)
|
|
877
|
+
// - string[] → array of URLs → indexed map via this.templates[0], …
|
|
878
|
+
// - { key: url, … } → named map → this.templates.key
|
|
879
|
+
//
|
|
880
|
+
// styleUrl accepts:
|
|
881
|
+
// - string → single stylesheet
|
|
882
|
+
// - string[] → array of URLs → all fetched & concatenated
|
|
883
|
+
//
|
|
884
|
+
// pages config (shorthand for multi-template + route-param page switching):
|
|
885
|
+
// pages: {
|
|
886
|
+
// dir: 'pages', // relative to component file (or base)
|
|
887
|
+
// param: 'section', // route param name → this.activePage
|
|
888
|
+
// default: 'getting-started', // fallback when param is absent
|
|
889
|
+
// ext: '.html', // file extension (default '.html')
|
|
890
|
+
// items: ['page-a', { id: 'page-b', label: 'Page B' }, ...]
|
|
891
|
+
// }
|
|
892
|
+
// Exposes this.pages (array of {id,label}), this.activePage (current id)
|
|
893
|
+
//
|
|
894
|
+
async _loadExternals() {
|
|
895
|
+
const def = this._def;
|
|
896
|
+
const base = def._base; // auto-detected or explicit
|
|
897
|
+
|
|
898
|
+
// ── Pages config ─────────────────────────────────────────────
|
|
899
|
+
if (def.pages && !def._pagesNormalized) {
|
|
900
|
+
const p = def.pages;
|
|
901
|
+
const ext = p.ext || '.html';
|
|
902
|
+
const dir = _resolveUrl((p.dir || '').replace(/\/+$/, ''), base);
|
|
903
|
+
|
|
904
|
+
// Normalize items → [{id, label}, …]
|
|
905
|
+
def._pages = (p.items || []).map(item => {
|
|
906
|
+
if (typeof item === 'string') return { id: item, label: _titleCase(item) };
|
|
907
|
+
return { id: item.id, label: item.label || _titleCase(item.id) };
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
// Auto-generate templateUrl object map
|
|
911
|
+
if (!def.templateUrl) {
|
|
912
|
+
def.templateUrl = {};
|
|
913
|
+
for (const { id } of def._pages) {
|
|
914
|
+
def.templateUrl[id] = `${dir}/${id}${ext}`;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
def._pagesNormalized = true;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ── External templates ──────────────────────────────────────
|
|
922
|
+
if (def.templateUrl && !def._templateLoaded) {
|
|
923
|
+
const tu = def.templateUrl;
|
|
924
|
+
if (typeof tu === 'string') {
|
|
925
|
+
def._externalTemplate = await _fetchResource(_resolveUrl(tu, base));
|
|
926
|
+
} else if (Array.isArray(tu)) {
|
|
927
|
+
const urls = tu.map(u => _resolveUrl(u, base));
|
|
928
|
+
const results = await Promise.all(urls.map(u => _fetchResource(u)));
|
|
929
|
+
def._externalTemplates = {};
|
|
930
|
+
results.forEach((html, i) => { def._externalTemplates[i] = html; });
|
|
931
|
+
} else if (typeof tu === 'object') {
|
|
932
|
+
const entries = Object.entries(tu);
|
|
933
|
+
// Pages config already resolved; plain objects still need resolving
|
|
934
|
+
const results = await Promise.all(
|
|
935
|
+
entries.map(([, url]) => _fetchResource(def._pagesNormalized ? url : _resolveUrl(url, base)))
|
|
936
|
+
);
|
|
937
|
+
def._externalTemplates = {};
|
|
938
|
+
entries.forEach(([key], i) => { def._externalTemplates[key] = results[i]; });
|
|
939
|
+
}
|
|
940
|
+
def._templateLoaded = true;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ── External styles ─────────────────────────────────────────
|
|
944
|
+
if (def.styleUrl && !def._styleLoaded) {
|
|
945
|
+
const su = def.styleUrl;
|
|
946
|
+
if (typeof su === 'string') {
|
|
947
|
+
def._externalStyles = await _fetchResource(_resolveUrl(su, base));
|
|
948
|
+
} else if (Array.isArray(su)) {
|
|
949
|
+
const urls = su.map(u => _resolveUrl(u, base));
|
|
950
|
+
const results = await Promise.all(urls.map(u => _fetchResource(u)));
|
|
951
|
+
def._externalStyles = results.join('\n');
|
|
952
|
+
}
|
|
953
|
+
def._styleLoaded = true;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Render the component
|
|
958
|
+
_render() {
|
|
959
|
+
// If externals haven't loaded yet, trigger async load then re-render
|
|
960
|
+
if ((this._def.templateUrl && !this._def._templateLoaded) ||
|
|
961
|
+
(this._def.styleUrl && !this._def._styleLoaded) ||
|
|
962
|
+
(this._def.pages && !this._def._pagesNormalized)) {
|
|
963
|
+
this._loadExternals().then(() => {
|
|
964
|
+
if (!this._destroyed) this._render();
|
|
965
|
+
});
|
|
966
|
+
return; // Skip this render — will re-render after load
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Expose multi-template map on instance (if available)
|
|
970
|
+
if (this._def._externalTemplates) {
|
|
971
|
+
this.templates = this._def._externalTemplates;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Expose pages metadata and active page (derived from route param)
|
|
975
|
+
if (this._def._pages) {
|
|
976
|
+
this.pages = this._def._pages;
|
|
977
|
+
const pc = this._def.pages;
|
|
978
|
+
this.activePage = (pc.param && this.props.$params?.[pc.param]) || pc.default || this._def._pages[0]?.id || '';
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Determine HTML content
|
|
982
|
+
let html;
|
|
983
|
+
if (this._def.render) {
|
|
984
|
+
// Inline render function takes priority
|
|
985
|
+
html = this._def.render.call(this);
|
|
986
|
+
} else if (this._def._externalTemplate) {
|
|
987
|
+
// External template with {{expression}} interpolation
|
|
988
|
+
html = this._def._externalTemplate.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
|
|
989
|
+
try {
|
|
990
|
+
return new Function('state', 'props', '$', `with(state){return ${expr.trim()}}`)(
|
|
991
|
+
this.state.__raw || this.state,
|
|
992
|
+
this.props,
|
|
993
|
+
typeof window !== 'undefined' ? window.$ : undefined
|
|
994
|
+
);
|
|
995
|
+
} catch { return ''; }
|
|
996
|
+
});
|
|
997
|
+
} else {
|
|
998
|
+
html = '';
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Combine inline styles + external styles
|
|
1002
|
+
const combinedStyles = [
|
|
1003
|
+
this._def.styles || '',
|
|
1004
|
+
this._def._externalStyles || ''
|
|
1005
|
+
].filter(Boolean).join('\n');
|
|
1006
|
+
|
|
1007
|
+
// Apply scoped styles on first render
|
|
1008
|
+
if (!this._mounted && combinedStyles) {
|
|
1009
|
+
const scopeAttr = `z-s${this._uid}`;
|
|
1010
|
+
this._el.setAttribute(scopeAttr, '');
|
|
1011
|
+
const scoped = combinedStyles.replace(/([^{}]+)\{/g, (match, selector) => {
|
|
1012
|
+
return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
|
|
1013
|
+
});
|
|
1014
|
+
const styleEl = document.createElement('style');
|
|
1015
|
+
styleEl.textContent = scoped;
|
|
1016
|
+
styleEl.setAttribute('data-zq-component', this._def._name || '');
|
|
1017
|
+
document.head.appendChild(styleEl);
|
|
1018
|
+
this._styleEl = styleEl;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ── Focus preservation for z-model ────────────────────────────
|
|
1022
|
+
// Before replacing innerHTML, save focus state so we can restore
|
|
1023
|
+
// cursor position after the DOM is rebuilt.
|
|
1024
|
+
let _focusInfo = null;
|
|
1025
|
+
const _active = document.activeElement;
|
|
1026
|
+
if (_active && this._el.contains(_active)) {
|
|
1027
|
+
const modelKey = _active.getAttribute?.('z-model');
|
|
1028
|
+
if (modelKey) {
|
|
1029
|
+
_focusInfo = {
|
|
1030
|
+
key: modelKey,
|
|
1031
|
+
start: _active.selectionStart,
|
|
1032
|
+
end: _active.selectionEnd,
|
|
1033
|
+
dir: _active.selectionDirection,
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Update DOM
|
|
1039
|
+
this._el.innerHTML = html;
|
|
1040
|
+
|
|
1041
|
+
// Process directives
|
|
1042
|
+
this._bindEvents();
|
|
1043
|
+
this._bindRefs();
|
|
1044
|
+
this._bindModels();
|
|
1045
|
+
|
|
1046
|
+
// Restore focus to z-model element after re-render
|
|
1047
|
+
if (_focusInfo) {
|
|
1048
|
+
const el = this._el.querySelector(`[z-model="${_focusInfo.key}"]`);
|
|
1049
|
+
if (el) {
|
|
1050
|
+
el.focus();
|
|
1051
|
+
try {
|
|
1052
|
+
if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
|
|
1053
|
+
el.setSelectionRange(_focusInfo.start, _focusInfo.end, _focusInfo.dir);
|
|
1054
|
+
}
|
|
1055
|
+
} catch (_) { /* some input types don't support setSelectionRange */ }
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Mount nested components
|
|
1060
|
+
mountAll(this._el);
|
|
1061
|
+
|
|
1062
|
+
if (!this._mounted) {
|
|
1063
|
+
this._mounted = true;
|
|
1064
|
+
if (this._def.mounted) this._def.mounted.call(this);
|
|
1065
|
+
} else {
|
|
1066
|
+
if (this._def.updated) this._def.updated.call(this);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Bind @event="method" handlers via delegation
|
|
1071
|
+
_bindEvents() {
|
|
1072
|
+
// Clean up old delegated listeners
|
|
1073
|
+
this._listeners.forEach(({ event, handler }) => {
|
|
1074
|
+
this._el.removeEventListener(event, handler);
|
|
1075
|
+
});
|
|
1076
|
+
this._listeners = [];
|
|
1077
|
+
|
|
1078
|
+
// Find all elements with @event attributes
|
|
1079
|
+
const allEls = this._el.querySelectorAll('*');
|
|
1080
|
+
const eventMap = new Map(); // event → [{ selector, method, modifiers }]
|
|
1081
|
+
|
|
1082
|
+
allEls.forEach(child => {
|
|
1083
|
+
[...child.attributes].forEach(attr => {
|
|
1084
|
+
if (!attr.name.startsWith('@')) return;
|
|
1085
|
+
|
|
1086
|
+
const raw = attr.name.slice(1); // e.g. "click.prevent"
|
|
1087
|
+
const parts = raw.split('.');
|
|
1088
|
+
const event = parts[0];
|
|
1089
|
+
const modifiers = parts.slice(1);
|
|
1090
|
+
const methodExpr = attr.value;
|
|
1091
|
+
|
|
1092
|
+
// Give element a unique selector for delegation
|
|
1093
|
+
if (!child.dataset.zqEid) {
|
|
1094
|
+
child.dataset.zqEid = String(++_uid);
|
|
1095
|
+
}
|
|
1096
|
+
const selector = `[data-zq-eid="${child.dataset.zqEid}"]`;
|
|
1097
|
+
|
|
1098
|
+
if (!eventMap.has(event)) eventMap.set(event, []);
|
|
1099
|
+
eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
// Register delegated listeners on the component root
|
|
1104
|
+
for (const [event, bindings] of eventMap) {
|
|
1105
|
+
const handler = (e) => {
|
|
1106
|
+
for (const { selector, methodExpr, modifiers, el } of bindings) {
|
|
1107
|
+
if (!e.target.closest(selector)) continue;
|
|
1108
|
+
|
|
1109
|
+
// Handle modifiers
|
|
1110
|
+
if (modifiers.includes('prevent')) e.preventDefault();
|
|
1111
|
+
if (modifiers.includes('stop')) e.stopPropagation();
|
|
1112
|
+
|
|
1113
|
+
// Parse method expression: "method" or "method(arg1, arg2)"
|
|
1114
|
+
const match = methodExpr.match(/^(\w+)(?:\(([^)]*)\))?$/);
|
|
1115
|
+
if (match) {
|
|
1116
|
+
const methodName = match[1];
|
|
1117
|
+
const fn = this[methodName];
|
|
1118
|
+
if (typeof fn === 'function') {
|
|
1119
|
+
if (match[2] !== undefined) {
|
|
1120
|
+
// Parse arguments (supports strings, numbers, state refs)
|
|
1121
|
+
const args = match[2].split(',').map(a => {
|
|
1122
|
+
a = a.trim();
|
|
1123
|
+
if (a === '') return undefined;
|
|
1124
|
+
if (a === 'true') return true;
|
|
1125
|
+
if (a === 'false') return false;
|
|
1126
|
+
if (a === 'null') return null;
|
|
1127
|
+
if (/^-?\d+(\.\d+)?$/.test(a)) return Number(a);
|
|
1128
|
+
if ((a.startsWith("'") && a.endsWith("'")) || (a.startsWith('"') && a.endsWith('"'))) return a.slice(1, -1);
|
|
1129
|
+
// State reference
|
|
1130
|
+
if (a.startsWith('state.')) return this.state[a.slice(6)];
|
|
1131
|
+
return a;
|
|
1132
|
+
}).filter(a => a !== undefined);
|
|
1133
|
+
fn(e, ...args);
|
|
1134
|
+
} else {
|
|
1135
|
+
fn(e);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
this._el.addEventListener(event, handler);
|
|
1142
|
+
this._listeners.push({ event, handler });
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Bind z-ref="name" → this.refs.name
|
|
1147
|
+
_bindRefs() {
|
|
1148
|
+
this.refs = {};
|
|
1149
|
+
this._el.querySelectorAll('[z-ref]').forEach(el => {
|
|
1150
|
+
this.refs[el.getAttribute('z-ref')] = el;
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Bind z-model="stateKey" for two-way binding
|
|
1155
|
+
//
|
|
1156
|
+
// Supported elements: input (text, number, range, checkbox, radio, date, color, …),
|
|
1157
|
+
// textarea, select (single & multiple), contenteditable
|
|
1158
|
+
// Nested state keys: z-model="user.name" → this.state.user.name
|
|
1159
|
+
// Modifiers (boolean attributes on the same element):
|
|
1160
|
+
// z-lazy — listen on 'change' instead of 'input' (update on blur / commit)
|
|
1161
|
+
// z-trim — trim whitespace before writing to state
|
|
1162
|
+
// z-number — force Number() conversion regardless of input type
|
|
1163
|
+
//
|
|
1164
|
+
// Writes to reactive state so the rest of the UI stays in sync.
|
|
1165
|
+
// Focus and cursor position are preserved in _render() via focusInfo.
|
|
1166
|
+
//
|
|
1167
|
+
_bindModels() {
|
|
1168
|
+
this._el.querySelectorAll('[z-model]').forEach(el => {
|
|
1169
|
+
const key = el.getAttribute('z-model');
|
|
1170
|
+
const tag = el.tagName.toLowerCase();
|
|
1171
|
+
const type = (el.type || '').toLowerCase();
|
|
1172
|
+
const isEditable = el.hasAttribute('contenteditable');
|
|
1173
|
+
|
|
1174
|
+
// Modifiers
|
|
1175
|
+
const isLazy = el.hasAttribute('z-lazy');
|
|
1176
|
+
const isTrim = el.hasAttribute('z-trim');
|
|
1177
|
+
const isNum = el.hasAttribute('z-number');
|
|
1178
|
+
|
|
1179
|
+
// Read current state value (supports dot-path keys)
|
|
1180
|
+
const currentVal = _getPath(this.state, key);
|
|
1181
|
+
|
|
1182
|
+
// ── Set initial DOM value from state ────────────────────────
|
|
1183
|
+
if (tag === 'input' && type === 'checkbox') {
|
|
1184
|
+
el.checked = !!currentVal;
|
|
1185
|
+
} else if (tag === 'input' && type === 'radio') {
|
|
1186
|
+
el.checked = el.value === String(currentVal);
|
|
1187
|
+
} else if (tag === 'select' && el.multiple) {
|
|
1188
|
+
const vals = Array.isArray(currentVal) ? currentVal.map(String) : [];
|
|
1189
|
+
[...el.options].forEach(opt => { opt.selected = vals.includes(opt.value); });
|
|
1190
|
+
} else if (isEditable) {
|
|
1191
|
+
if (el.textContent !== String(currentVal ?? '')) {
|
|
1192
|
+
el.textContent = currentVal ?? '';
|
|
1193
|
+
}
|
|
1194
|
+
} else {
|
|
1195
|
+
el.value = currentVal ?? '';
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// ── Determine event type ────────────────────────────────────
|
|
1199
|
+
const event = isLazy || tag === 'select' || type === 'checkbox' || type === 'radio'
|
|
1200
|
+
? 'change'
|
|
1201
|
+
: isEditable ? 'input' : 'input';
|
|
1202
|
+
|
|
1203
|
+
// ── Handler: read DOM → write to reactive state ─────────────
|
|
1204
|
+
const handler = () => {
|
|
1205
|
+
let val;
|
|
1206
|
+
if (type === 'checkbox') val = el.checked;
|
|
1207
|
+
else if (tag === 'select' && el.multiple) val = [...el.selectedOptions].map(o => o.value);
|
|
1208
|
+
else if (isEditable) val = el.textContent;
|
|
1209
|
+
else val = el.value;
|
|
1210
|
+
|
|
1211
|
+
// Apply modifiers
|
|
1212
|
+
if (isTrim && typeof val === 'string') val = val.trim();
|
|
1213
|
+
if (isNum || type === 'number' || type === 'range') val = Number(val);
|
|
1214
|
+
|
|
1215
|
+
// Write through the reactive proxy (triggers re-render).
|
|
1216
|
+
// Focus + cursor are preserved automatically by _render().
|
|
1217
|
+
_setPath(this.state, key, val);
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
el.addEventListener(event, handler);
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Programmatic state update (batch-friendly)
|
|
1225
|
+
setState(partial) {
|
|
1226
|
+
Object.assign(this.state, partial);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Emit custom event up the DOM
|
|
1230
|
+
emit(name, detail) {
|
|
1231
|
+
this._el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, cancelable: true }));
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Destroy this component
|
|
1235
|
+
destroy() {
|
|
1236
|
+
if (this._destroyed) return;
|
|
1237
|
+
this._destroyed = true;
|
|
1238
|
+
if (this._def.destroyed) this._def.destroyed.call(this);
|
|
1239
|
+
this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
|
|
1240
|
+
this._listeners = [];
|
|
1241
|
+
if (this._styleEl) this._styleEl.remove();
|
|
1242
|
+
_instances.delete(this._el);
|
|
1243
|
+
this._el.innerHTML = '';
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
// Reserved definition keys (not user methods)
|
|
1249
|
+
const _reservedKeys = new Set([
|
|
1250
|
+
'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
|
|
1251
|
+
'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base'
|
|
1252
|
+
]);
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
// ---------------------------------------------------------------------------
|
|
1256
|
+
// Public API
|
|
1257
|
+
// ---------------------------------------------------------------------------
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Register a component
|
|
1261
|
+
* @param {string} name — tag name (must contain a hyphen, e.g. 'app-counter')
|
|
1262
|
+
* @param {object} definition — component definition
|
|
1263
|
+
*/
|
|
1264
|
+
function component(name, definition) {
|
|
1265
|
+
if (!name.includes('-')) {
|
|
1266
|
+
throw new Error(`zQuery: Component name "${name}" must contain a hyphen (Web Component convention)`);
|
|
1267
|
+
}
|
|
1268
|
+
definition._name = name;
|
|
1269
|
+
|
|
1270
|
+
// Auto-detect the calling module's URL so that relative templateUrl,
|
|
1271
|
+
// styleUrl, and pages.dir paths resolve relative to the component file.
|
|
1272
|
+
// An explicit `base` string on the definition overrides auto-detection.
|
|
1273
|
+
if (definition.base !== undefined) {
|
|
1274
|
+
definition._base = definition.base; // explicit override
|
|
1275
|
+
} else {
|
|
1276
|
+
definition._base = _detectCallerBase();
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
_registry.set(name, definition);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Mount a component into a target element
|
|
1284
|
+
* @param {string|Element} target — selector or element to mount into
|
|
1285
|
+
* @param {string} componentName — registered component name
|
|
1286
|
+
* @param {object} props — props to pass
|
|
1287
|
+
* @returns {Component}
|
|
1288
|
+
*/
|
|
1289
|
+
function mount(target, componentName, props = {}) {
|
|
1290
|
+
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
1291
|
+
if (!el) throw new Error(`zQuery: Mount target "${target}" not found`);
|
|
1292
|
+
|
|
1293
|
+
const def = _registry.get(componentName);
|
|
1294
|
+
if (!def) throw new Error(`zQuery: Component "${componentName}" not registered`);
|
|
1295
|
+
|
|
1296
|
+
// Destroy existing instance
|
|
1297
|
+
if (_instances.has(el)) _instances.get(el).destroy();
|
|
1298
|
+
|
|
1299
|
+
const instance = new Component(el, def, props);
|
|
1300
|
+
_instances.set(el, instance);
|
|
1301
|
+
instance._render();
|
|
1302
|
+
return instance;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Scan a container for custom component tags and auto-mount them
|
|
1307
|
+
* @param {Element} root — root element to scan (default: document.body)
|
|
1308
|
+
*/
|
|
1309
|
+
function mountAll(root = document.body) {
|
|
1310
|
+
for (const [name, def] of _registry) {
|
|
1311
|
+
const tags = root.querySelectorAll(name);
|
|
1312
|
+
tags.forEach(tag => {
|
|
1313
|
+
if (_instances.has(tag)) return; // Already mounted
|
|
1314
|
+
|
|
1315
|
+
// Extract props from attributes
|
|
1316
|
+
const props = {};
|
|
1317
|
+
[...tag.attributes].forEach(attr => {
|
|
1318
|
+
if (!attr.name.startsWith('@') && !attr.name.startsWith('z-')) {
|
|
1319
|
+
// Try JSON parse for objects/arrays
|
|
1320
|
+
try { props[attr.name] = JSON.parse(attr.value); }
|
|
1321
|
+
catch { props[attr.name] = attr.value; }
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
const instance = new Component(tag, def, props);
|
|
1326
|
+
_instances.set(tag, instance);
|
|
1327
|
+
instance._render();
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Get the component instance for an element
|
|
1334
|
+
* @param {string|Element} target
|
|
1335
|
+
* @returns {Component|null}
|
|
1336
|
+
*/
|
|
1337
|
+
function getInstance(target) {
|
|
1338
|
+
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
1339
|
+
return _instances.get(el) || null;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Destroy a component at the given target
|
|
1344
|
+
* @param {string|Element} target
|
|
1345
|
+
*/
|
|
1346
|
+
function destroy(target) {
|
|
1347
|
+
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
1348
|
+
const inst = _instances.get(el);
|
|
1349
|
+
if (inst) inst.destroy();
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Get the registry (for debugging)
|
|
1354
|
+
*/
|
|
1355
|
+
function getRegistry() {
|
|
1356
|
+
return Object.fromEntries(_registry);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
// ---------------------------------------------------------------------------
|
|
1361
|
+
// Global stylesheet loader
|
|
1362
|
+
// ---------------------------------------------------------------------------
|
|
1363
|
+
const _globalStyles = new Map(); // url → <link> element
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Load one or more global stylesheets into <head>.
|
|
1367
|
+
* Relative URLs resolve against the calling module's directory (auto-detected
|
|
1368
|
+
* from the stack trace), just like component styleUrl paths.
|
|
1369
|
+
* Returns a remove() handle so the caller can unload if needed.
|
|
1370
|
+
*
|
|
1371
|
+
* $.style('app.css') // critical by default
|
|
1372
|
+
* $.style(['app.css', 'theme.css']) // multiple files
|
|
1373
|
+
* $.style('/assets/global.css') // absolute — used as-is
|
|
1374
|
+
* $.style('app.css', { critical: false }) // opt out of FOUC prevention
|
|
1375
|
+
*
|
|
1376
|
+
* Options:
|
|
1377
|
+
* critical — (boolean, default true) When true, zQuery injects a tiny
|
|
1378
|
+
* inline style that hides the page (`visibility: hidden`) and
|
|
1379
|
+
* removes it once the stylesheet has loaded. This prevents
|
|
1380
|
+
* FOUC (Flash of Unstyled Content) entirely — no special
|
|
1381
|
+
* markup needed in the HTML file. Set to false to load
|
|
1382
|
+
* the stylesheet without blocking paint.
|
|
1383
|
+
* bg — (string, default '#0d1117') Background color applied while
|
|
1384
|
+
* the page is hidden during critical load. Prevents a white
|
|
1385
|
+
* flash on dark-themed apps. Only used when critical is true.
|
|
1386
|
+
*
|
|
1387
|
+
* Duplicate URLs are ignored (idempotent).
|
|
1388
|
+
*
|
|
1389
|
+
* @param {string|string[]} urls — stylesheet URL(s) to load
|
|
1390
|
+
* @param {object} [opts] — options
|
|
1391
|
+
* @param {boolean} [opts.critical=true] — hide page until loaded (prevents FOUC)
|
|
1392
|
+
* @param {string} [opts.bg] — background color while hidden (default '#0d1117')
|
|
1393
|
+
* @returns {{ remove: Function, ready: Promise }} — .remove() to unload, .ready resolves when loaded
|
|
1394
|
+
*/
|
|
1395
|
+
function style(urls, opts = {}) {
|
|
1396
|
+
const callerBase = _detectCallerBase();
|
|
1397
|
+
const list = Array.isArray(urls) ? urls : [urls];
|
|
1398
|
+
const elements = [];
|
|
1399
|
+
const loadPromises = [];
|
|
1400
|
+
|
|
1401
|
+
// Critical mode (default: true): inject a tiny inline <style> that hides the
|
|
1402
|
+
// page and sets a background color. Fully self-contained — no markup needed
|
|
1403
|
+
// in the HTML file. The style is removed once the sheet loads.
|
|
1404
|
+
let _criticalStyle = null;
|
|
1405
|
+
if (opts.critical !== false) {
|
|
1406
|
+
_criticalStyle = document.createElement('style');
|
|
1407
|
+
_criticalStyle.setAttribute('data-zq-critical', '');
|
|
1408
|
+
_criticalStyle.textContent = `html{visibility:hidden!important;background:${opts.bg || '#0d1117'}}`;
|
|
1409
|
+
document.head.insertBefore(_criticalStyle, document.head.firstChild);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
for (let url of list) {
|
|
1413
|
+
// Resolve relative paths against the caller's directory first,
|
|
1414
|
+
// falling back to <base href> or origin root.
|
|
1415
|
+
if (typeof url === 'string' && !url.startsWith('/') && !url.includes(':') && !url.startsWith('//')) {
|
|
1416
|
+
url = _resolveUrl(url, callerBase);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
if (_globalStyles.has(url)) {
|
|
1420
|
+
elements.push(_globalStyles.get(url));
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const link = document.createElement('link');
|
|
1425
|
+
link.rel = 'stylesheet';
|
|
1426
|
+
link.href = url;
|
|
1427
|
+
link.setAttribute('data-zq-style', '');
|
|
1428
|
+
|
|
1429
|
+
const p = new Promise(resolve => {
|
|
1430
|
+
link.onload = resolve;
|
|
1431
|
+
link.onerror = resolve; // don't block forever on error
|
|
1432
|
+
});
|
|
1433
|
+
loadPromises.push(p);
|
|
1434
|
+
|
|
1435
|
+
document.head.appendChild(link);
|
|
1436
|
+
_globalStyles.set(url, link);
|
|
1437
|
+
elements.push(link);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// When all sheets are loaded, reveal the page if critical mode was used
|
|
1441
|
+
const ready = Promise.all(loadPromises).then(() => {
|
|
1442
|
+
if (_criticalStyle) {
|
|
1443
|
+
_criticalStyle.remove();
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
return {
|
|
1448
|
+
ready,
|
|
1449
|
+
remove() {
|
|
1450
|
+
for (const el of elements) {
|
|
1451
|
+
el.remove();
|
|
1452
|
+
for (const [k, v] of _globalStyles) {
|
|
1453
|
+
if (v === el) { _globalStyles.delete(k); break; }
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// --- src/router.js ———————————————————————————————————————————————
|
|
1461
|
+
/**
|
|
1462
|
+
* zQuery Router — Client-side SPA router
|
|
1463
|
+
*
|
|
1464
|
+
* Supports hash mode (#/path) and history mode (/path).
|
|
1465
|
+
* Route params, query strings, navigation guards, and lazy loading.
|
|
1466
|
+
*
|
|
1467
|
+
* Usage:
|
|
1468
|
+
* $.router({
|
|
1469
|
+
* el: '#app',
|
|
1470
|
+
* mode: 'hash',
|
|
1471
|
+
* routes: [
|
|
1472
|
+
* { path: '/', component: 'home-page' },
|
|
1473
|
+
* { path: '/user/:id', component: 'user-profile' },
|
|
1474
|
+
* { path: '/lazy', load: () => import('./pages/lazy.js'), component: 'lazy-page' },
|
|
1475
|
+
* ],
|
|
1476
|
+
* fallback: 'not-found'
|
|
1477
|
+
* });
|
|
1478
|
+
*/
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
class Router {
|
|
1482
|
+
constructor(config = {}) {
|
|
1483
|
+
this._el = null;
|
|
1484
|
+
// Auto-detect: file:// protocol can't use pushState, fall back to hash
|
|
1485
|
+
const isFile = typeof location !== 'undefined' && location.protocol === 'file:';
|
|
1486
|
+
this._mode = config.mode || (isFile ? 'hash' : 'history');
|
|
1487
|
+
|
|
1488
|
+
// Base path for sub-path deployments
|
|
1489
|
+
// Priority: explicit config.base → window.__ZQ_BASE → <base href> tag
|
|
1490
|
+
let rawBase = config.base;
|
|
1491
|
+
if (rawBase == null) {
|
|
1492
|
+
rawBase = (typeof window !== 'undefined' && window.__ZQ_BASE) || '';
|
|
1493
|
+
if (!rawBase && typeof document !== 'undefined') {
|
|
1494
|
+
const baseEl = document.querySelector('base');
|
|
1495
|
+
if (baseEl) {
|
|
1496
|
+
try { rawBase = new URL(baseEl.href).pathname; }
|
|
1497
|
+
catch { rawBase = baseEl.getAttribute('href') || ''; }
|
|
1498
|
+
if (rawBase === '/') rawBase = ''; // root = no sub-path
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
// Normalize: ensure leading /, strip trailing /
|
|
1503
|
+
this._base = String(rawBase).replace(/\/+$/, '');
|
|
1504
|
+
if (this._base && !this._base.startsWith('/')) this._base = '/' + this._base;
|
|
1505
|
+
|
|
1506
|
+
this._routes = [];
|
|
1507
|
+
this._fallback = config.fallback || null;
|
|
1508
|
+
this._current = null; // { route, params, query, path }
|
|
1509
|
+
this._guards = { before: [], after: [] };
|
|
1510
|
+
this._listeners = new Set();
|
|
1511
|
+
this._instance = null; // current mounted component
|
|
1512
|
+
this._resolving = false; // re-entrancy guard
|
|
1513
|
+
|
|
1514
|
+
// Set outlet element
|
|
1515
|
+
if (config.el) {
|
|
1516
|
+
this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Register routes
|
|
1520
|
+
if (config.routes) {
|
|
1521
|
+
config.routes.forEach(r => this.add(r));
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// Listen for navigation
|
|
1525
|
+
if (this._mode === 'hash') {
|
|
1526
|
+
window.addEventListener('hashchange', () => this._resolve());
|
|
1527
|
+
} else {
|
|
1528
|
+
window.addEventListener('popstate', () => this._resolve());
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// Intercept link clicks for SPA navigation
|
|
1532
|
+
document.addEventListener('click', (e) => {
|
|
1533
|
+
// Don't intercept modified clicks (Ctrl/Cmd+click = new tab)
|
|
1534
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
1535
|
+
const link = e.target.closest('[z-link]');
|
|
1536
|
+
if (!link) return;
|
|
1537
|
+
if (link.getAttribute('target') === '_blank') return;
|
|
1538
|
+
e.preventDefault();
|
|
1539
|
+
this.navigate(link.getAttribute('z-link'));
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
// Initial resolve
|
|
1543
|
+
if (this._el) {
|
|
1544
|
+
// Defer to allow all components to register
|
|
1545
|
+
queueMicrotask(() => this._resolve());
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// --- Route management ----------------------------------------------------
|
|
1550
|
+
|
|
1551
|
+
add(route) {
|
|
1552
|
+
// Compile path pattern into regex
|
|
1553
|
+
const keys = [];
|
|
1554
|
+
const pattern = route.path
|
|
1555
|
+
.replace(/:(\w+)/g, (_, key) => { keys.push(key); return '([^/]+)'; })
|
|
1556
|
+
.replace(/\*/g, '(.*)');
|
|
1557
|
+
const regex = new RegExp(`^${pattern}$`);
|
|
1558
|
+
|
|
1559
|
+
this._routes.push({ ...route, _regex: regex, _keys: keys });
|
|
1560
|
+
|
|
1561
|
+
// Per-route fallback: register an alias path for the same component.
|
|
1562
|
+
// e.g. { path: '/docs/:section', fallback: '/docs', component: 'docs-page' }
|
|
1563
|
+
// When matched via fallback, missing params are undefined → pages `default` kicks in.
|
|
1564
|
+
if (route.fallback) {
|
|
1565
|
+
const fbKeys = [];
|
|
1566
|
+
const fbPattern = route.fallback
|
|
1567
|
+
.replace(/:(\w+)/g, (_, key) => { fbKeys.push(key); return '([^/]+)'; })
|
|
1568
|
+
.replace(/\*/g, '(.*)');
|
|
1569
|
+
const fbRegex = new RegExp(`^${fbPattern}$`);
|
|
1570
|
+
this._routes.push({ ...route, path: route.fallback, _regex: fbRegex, _keys: fbKeys });
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
return this;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
remove(path) {
|
|
1577
|
+
this._routes = this._routes.filter(r => r.path !== path);
|
|
1578
|
+
return this;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// --- Navigation ----------------------------------------------------------
|
|
1582
|
+
|
|
1583
|
+
navigate(path, options = {}) {
|
|
1584
|
+
// Separate hash fragment (e.g. /docs/getting-started#cli-bundler)
|
|
1585
|
+
const [cleanPath, fragment] = (path || '').split('#');
|
|
1586
|
+
let normalized = this._normalizePath(cleanPath);
|
|
1587
|
+
const hash = fragment ? '#' + fragment : '';
|
|
1588
|
+
if (this._mode === 'hash') {
|
|
1589
|
+
// Hash mode uses the URL hash for routing, so a #fragment can't live
|
|
1590
|
+
// in the URL. Store it as a scroll target for the destination component.
|
|
1591
|
+
if (fragment) window.__zqScrollTarget = fragment;
|
|
1592
|
+
window.location.hash = '#' + normalized;
|
|
1593
|
+
} else {
|
|
1594
|
+
window.history.pushState(options.state || {}, '', this._base + normalized + hash);
|
|
1595
|
+
this._resolve();
|
|
1596
|
+
}
|
|
1597
|
+
return this;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
replace(path, options = {}) {
|
|
1601
|
+
const [cleanPath, fragment] = (path || '').split('#');
|
|
1602
|
+
let normalized = this._normalizePath(cleanPath);
|
|
1603
|
+
const hash = fragment ? '#' + fragment : '';
|
|
1604
|
+
if (this._mode === 'hash') {
|
|
1605
|
+
if (fragment) window.__zqScrollTarget = fragment;
|
|
1606
|
+
window.location.replace('#' + normalized);
|
|
1607
|
+
} else {
|
|
1608
|
+
window.history.replaceState(options.state || {}, '', this._base + normalized + hash);
|
|
1609
|
+
this._resolve();
|
|
1610
|
+
}
|
|
1611
|
+
return this;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Normalize an app-relative path and guard against double base-prefixing.
|
|
1616
|
+
* @param {string} path — e.g. '/docs', 'docs', or '/app/docs' when base is '/app'
|
|
1617
|
+
* @returns {string} — always starts with '/'
|
|
1618
|
+
*/
|
|
1619
|
+
_normalizePath(path) {
|
|
1620
|
+
let p = path && path.startsWith('/') ? path : (path ? `/${path}` : '/');
|
|
1621
|
+
// Strip base prefix if caller accidentally included it
|
|
1622
|
+
if (this._base) {
|
|
1623
|
+
if (p === this._base) return '/';
|
|
1624
|
+
if (p.startsWith(this._base + '/')) p = p.slice(this._base.length) || '/';
|
|
1625
|
+
}
|
|
1626
|
+
return p;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* Resolve an app-relative path to a full URL path (including base).
|
|
1631
|
+
* Useful for programmatic link generation.
|
|
1632
|
+
* @param {string} path
|
|
1633
|
+
* @returns {string}
|
|
1634
|
+
*/
|
|
1635
|
+
resolve(path) {
|
|
1636
|
+
const normalized = path && path.startsWith('/') ? path : (path ? `/${path}` : '/');
|
|
1637
|
+
return this._base + normalized;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
back() { window.history.back(); return this; }
|
|
1641
|
+
forward() { window.history.forward(); return this; }
|
|
1642
|
+
go(n) { window.history.go(n); return this; }
|
|
1643
|
+
|
|
1644
|
+
// --- Guards --------------------------------------------------------------
|
|
1645
|
+
|
|
1646
|
+
beforeEach(fn) {
|
|
1647
|
+
this._guards.before.push(fn);
|
|
1648
|
+
return this;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
afterEach(fn) {
|
|
1652
|
+
this._guards.after.push(fn);
|
|
1653
|
+
return this;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// --- Events --------------------------------------------------------------
|
|
1657
|
+
|
|
1658
|
+
onChange(fn) {
|
|
1659
|
+
this._listeners.add(fn);
|
|
1660
|
+
return () => this._listeners.delete(fn);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// --- Current state -------------------------------------------------------
|
|
1664
|
+
|
|
1665
|
+
get current() { return this._current; }
|
|
1666
|
+
|
|
1667
|
+
/** The detected or configured base path (read-only) */
|
|
1668
|
+
get base() { return this._base; }
|
|
1669
|
+
|
|
1670
|
+
get path() {
|
|
1671
|
+
if (this._mode === 'hash') {
|
|
1672
|
+
const raw = window.location.hash.slice(1) || '/';
|
|
1673
|
+
// If the hash doesn't start with '/', it's an in-page anchor
|
|
1674
|
+
// (e.g. #some-heading), not a route. Treat it as a scroll target
|
|
1675
|
+
// and resolve to the last known route (or '/').
|
|
1676
|
+
if (raw && !raw.startsWith('/')) {
|
|
1677
|
+
window.__zqScrollTarget = raw;
|
|
1678
|
+
// Restore the route hash silently so the URL stays valid
|
|
1679
|
+
const fallbackPath = (this._current && this._current.path) || '/';
|
|
1680
|
+
window.location.replace('#' + fallbackPath);
|
|
1681
|
+
return fallbackPath;
|
|
1682
|
+
}
|
|
1683
|
+
return raw;
|
|
1684
|
+
}
|
|
1685
|
+
let pathname = window.location.pathname || '/';
|
|
1686
|
+
// Strip trailing slash for consistency (except root '/')
|
|
1687
|
+
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
1688
|
+
pathname = pathname.slice(0, -1);
|
|
1689
|
+
}
|
|
1690
|
+
if (this._base) {
|
|
1691
|
+
// Exact match: /app
|
|
1692
|
+
if (pathname === this._base) return '/';
|
|
1693
|
+
// Prefix match with boundary: /app/page (but NOT /application)
|
|
1694
|
+
if (pathname.startsWith(this._base + '/')) {
|
|
1695
|
+
return pathname.slice(this._base.length) || '/';
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return pathname;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
get query() {
|
|
1702
|
+
const search = this._mode === 'hash'
|
|
1703
|
+
? (window.location.hash.split('?')[1] || '')
|
|
1704
|
+
: window.location.search.slice(1);
|
|
1705
|
+
return Object.fromEntries(new URLSearchParams(search));
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// --- Internal resolve ----------------------------------------------------
|
|
1709
|
+
|
|
1710
|
+
async _resolve() {
|
|
1711
|
+
// Prevent re-entrant calls (e.g. listener triggering navigation)
|
|
1712
|
+
if (this._resolving) return;
|
|
1713
|
+
this._resolving = true;
|
|
1714
|
+
try {
|
|
1715
|
+
await this.__resolve();
|
|
1716
|
+
} finally {
|
|
1717
|
+
this._resolving = false;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
async __resolve() {
|
|
1722
|
+
const fullPath = this.path;
|
|
1723
|
+
const [pathPart, queryString] = fullPath.split('?');
|
|
1724
|
+
const path = pathPart || '/';
|
|
1725
|
+
const query = Object.fromEntries(new URLSearchParams(queryString || ''));
|
|
1726
|
+
|
|
1727
|
+
// Match route
|
|
1728
|
+
let matched = null;
|
|
1729
|
+
let params = {};
|
|
1730
|
+
for (const route of this._routes) {
|
|
1731
|
+
const m = path.match(route._regex);
|
|
1732
|
+
if (m) {
|
|
1733
|
+
matched = route;
|
|
1734
|
+
route._keys.forEach((key, i) => { params[key] = m[i + 1]; });
|
|
1735
|
+
break;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// Fallback
|
|
1740
|
+
if (!matched && this._fallback) {
|
|
1741
|
+
matched = { component: this._fallback, path: '*', _keys: [], _regex: /.*/ };
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
if (!matched) return;
|
|
1745
|
+
|
|
1746
|
+
const to = { route: matched, params, query, path };
|
|
1747
|
+
const from = this._current;
|
|
1748
|
+
|
|
1749
|
+
// Run before guards
|
|
1750
|
+
for (const guard of this._guards.before) {
|
|
1751
|
+
const result = await guard(to, from);
|
|
1752
|
+
if (result === false) return; // Cancel
|
|
1753
|
+
if (typeof result === 'string') { // Redirect
|
|
1754
|
+
return this.navigate(result);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// Lazy load module if needed
|
|
1759
|
+
if (matched.load) {
|
|
1760
|
+
try { await matched.load(); }
|
|
1761
|
+
catch (err) {
|
|
1762
|
+
console.error(`zQuery Router: Failed to load module for "${matched.path}"`, err);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
this._current = to;
|
|
1768
|
+
|
|
1769
|
+
// Mount component into outlet
|
|
1770
|
+
if (this._el && matched.component) {
|
|
1771
|
+
// Destroy previous
|
|
1772
|
+
if (this._instance) {
|
|
1773
|
+
this._instance.destroy();
|
|
1774
|
+
this._instance = null;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// Create container
|
|
1778
|
+
this._el.innerHTML = '';
|
|
1779
|
+
|
|
1780
|
+
// Pass route params and query as props
|
|
1781
|
+
const props = { ...params, $route: to, $query: query, $params: params };
|
|
1782
|
+
|
|
1783
|
+
// If component is a string (registered name), mount it
|
|
1784
|
+
if (typeof matched.component === 'string') {
|
|
1785
|
+
const container = document.createElement(matched.component);
|
|
1786
|
+
this._el.appendChild(container);
|
|
1787
|
+
this._instance = mount(container, matched.component, props);
|
|
1788
|
+
}
|
|
1789
|
+
// If component is a render function
|
|
1790
|
+
else if (typeof matched.component === 'function') {
|
|
1791
|
+
this._el.innerHTML = matched.component(to);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// Run after guards
|
|
1796
|
+
for (const guard of this._guards.after) {
|
|
1797
|
+
await guard(to, from);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// Notify listeners
|
|
1801
|
+
this._listeners.forEach(fn => fn(to, from));
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// --- Destroy -------------------------------------------------------------
|
|
1805
|
+
|
|
1806
|
+
destroy() {
|
|
1807
|
+
if (this._instance) this._instance.destroy();
|
|
1808
|
+
this._listeners.clear();
|
|
1809
|
+
this._routes = [];
|
|
1810
|
+
this._guards = { before: [], after: [] };
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
// ---------------------------------------------------------------------------
|
|
1816
|
+
// Factory
|
|
1817
|
+
// ---------------------------------------------------------------------------
|
|
1818
|
+
let _activeRouter = null;
|
|
1819
|
+
|
|
1820
|
+
function createRouter(config) {
|
|
1821
|
+
_activeRouter = new Router(config);
|
|
1822
|
+
return _activeRouter;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function getRouter() {
|
|
1826
|
+
return _activeRouter;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// --- src/store.js ————————————————————————————————————————————————
|
|
1830
|
+
/**
|
|
1831
|
+
* zQuery Store — Global reactive state management
|
|
1832
|
+
*
|
|
1833
|
+
* A lightweight Redux/Vuex-inspired store with:
|
|
1834
|
+
* - Reactive state via Proxy
|
|
1835
|
+
* - Named actions for mutations
|
|
1836
|
+
* - Key-specific subscriptions
|
|
1837
|
+
* - Computed getters
|
|
1838
|
+
* - Middleware support
|
|
1839
|
+
* - DevTools-friendly (logs actions in dev mode)
|
|
1840
|
+
*
|
|
1841
|
+
* Usage:
|
|
1842
|
+
* const store = $.store({
|
|
1843
|
+
* state: { count: 0, user: null },
|
|
1844
|
+
* actions: {
|
|
1845
|
+
* increment(state) { state.count++; },
|
|
1846
|
+
* setUser(state, user) { state.user = user; }
|
|
1847
|
+
* },
|
|
1848
|
+
* getters: {
|
|
1849
|
+
* doubleCount: (state) => state.count * 2
|
|
1850
|
+
* }
|
|
1851
|
+
* });
|
|
1852
|
+
*
|
|
1853
|
+
* store.dispatch('increment');
|
|
1854
|
+
* store.subscribe('count', (val, old) => console.log(val));
|
|
1855
|
+
*/
|
|
1856
|
+
|
|
1857
|
+
|
|
1858
|
+
class Store {
|
|
1859
|
+
constructor(config = {}) {
|
|
1860
|
+
this._subscribers = new Map(); // key → Set<fn>
|
|
1861
|
+
this._wildcards = new Set(); // subscribe to all changes
|
|
1862
|
+
this._actions = config.actions || {};
|
|
1863
|
+
this._getters = config.getters || {};
|
|
1864
|
+
this._middleware = [];
|
|
1865
|
+
this._history = []; // action log
|
|
1866
|
+
this._debug = config.debug || false;
|
|
1867
|
+
|
|
1868
|
+
// Create reactive state
|
|
1869
|
+
const initial = typeof config.state === 'function' ? config.state() : { ...(config.state || {}) };
|
|
1870
|
+
|
|
1871
|
+
this.state = reactive(initial, (key, value, old) => {
|
|
1872
|
+
// Notify key-specific subscribers
|
|
1873
|
+
const subs = this._subscribers.get(key);
|
|
1874
|
+
if (subs) subs.forEach(fn => fn(value, old, key));
|
|
1875
|
+
// Notify wildcard subscribers
|
|
1876
|
+
this._wildcards.forEach(fn => fn(key, value, old));
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
// Build getters as computed properties
|
|
1880
|
+
this.getters = {};
|
|
1881
|
+
for (const [name, fn] of Object.entries(this._getters)) {
|
|
1882
|
+
Object.defineProperty(this.getters, name, {
|
|
1883
|
+
get: () => fn(this.state.__raw || this.state),
|
|
1884
|
+
enumerable: true
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/**
|
|
1890
|
+
* Dispatch a named action
|
|
1891
|
+
* @param {string} name — action name
|
|
1892
|
+
* @param {...any} args — payload
|
|
1893
|
+
*/
|
|
1894
|
+
dispatch(name, ...args) {
|
|
1895
|
+
const action = this._actions[name];
|
|
1896
|
+
if (!action) {
|
|
1897
|
+
console.warn(`zQuery Store: Unknown action "${name}"`);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// Run middleware
|
|
1902
|
+
for (const mw of this._middleware) {
|
|
1903
|
+
const result = mw(name, args, this.state);
|
|
1904
|
+
if (result === false) return; // blocked by middleware
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
if (this._debug) {
|
|
1908
|
+
console.log(`%c[Store] ${name}`, 'color: #4CAF50; font-weight: bold;', ...args);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
const result = action(this.state, ...args);
|
|
1912
|
+
this._history.push({ action: name, args, timestamp: Date.now() });
|
|
1913
|
+
return result;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* Subscribe to changes on a specific state key
|
|
1918
|
+
* @param {string|Function} keyOrFn — state key, or function for all changes
|
|
1919
|
+
* @param {Function} [fn] — callback (value, oldValue, key)
|
|
1920
|
+
* @returns {Function} — unsubscribe
|
|
1921
|
+
*/
|
|
1922
|
+
subscribe(keyOrFn, fn) {
|
|
1923
|
+
if (typeof keyOrFn === 'function') {
|
|
1924
|
+
// Wildcard — listen to all changes
|
|
1925
|
+
this._wildcards.add(keyOrFn);
|
|
1926
|
+
return () => this._wildcards.delete(keyOrFn);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
if (!this._subscribers.has(keyOrFn)) {
|
|
1930
|
+
this._subscribers.set(keyOrFn, new Set());
|
|
1931
|
+
}
|
|
1932
|
+
this._subscribers.get(keyOrFn).add(fn);
|
|
1933
|
+
return () => this._subscribers.get(keyOrFn)?.delete(fn);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* Get current state snapshot (plain object)
|
|
1938
|
+
*/
|
|
1939
|
+
snapshot() {
|
|
1940
|
+
return JSON.parse(JSON.stringify(this.state.__raw || this.state));
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Replace entire state
|
|
1945
|
+
*/
|
|
1946
|
+
replaceState(newState) {
|
|
1947
|
+
const raw = this.state.__raw || this.state;
|
|
1948
|
+
for (const key of Object.keys(raw)) {
|
|
1949
|
+
delete this.state[key];
|
|
1950
|
+
}
|
|
1951
|
+
Object.assign(this.state, newState);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/**
|
|
1955
|
+
* Add middleware: fn(actionName, args, state) → false to block
|
|
1956
|
+
*/
|
|
1957
|
+
use(fn) {
|
|
1958
|
+
this._middleware.push(fn);
|
|
1959
|
+
return this;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* Get action history
|
|
1964
|
+
*/
|
|
1965
|
+
get history() {
|
|
1966
|
+
return [...this._history];
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/**
|
|
1970
|
+
* Reset state to initial values
|
|
1971
|
+
*/
|
|
1972
|
+
reset(initialState) {
|
|
1973
|
+
this.replaceState(initialState);
|
|
1974
|
+
this._history = [];
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
// ---------------------------------------------------------------------------
|
|
1980
|
+
// Factory
|
|
1981
|
+
// ---------------------------------------------------------------------------
|
|
1982
|
+
let _stores = new Map();
|
|
1983
|
+
|
|
1984
|
+
function createStore(name, config) {
|
|
1985
|
+
// If called with just config (no name), use 'default'
|
|
1986
|
+
if (typeof name === 'object') {
|
|
1987
|
+
config = name;
|
|
1988
|
+
name = 'default';
|
|
1989
|
+
}
|
|
1990
|
+
const store = new Store(config);
|
|
1991
|
+
_stores.set(name, store);
|
|
1992
|
+
return store;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function getStore(name = 'default') {
|
|
1996
|
+
return _stores.get(name) || null;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// --- src/http.js —————————————————————————————————————————————————
|
|
2000
|
+
/**
|
|
2001
|
+
* zQuery HTTP — Lightweight fetch wrapper
|
|
2002
|
+
*
|
|
2003
|
+
* Clean API for GET/POST/PUT/PATCH/DELETE with:
|
|
2004
|
+
* - Auto JSON serialization/deserialization
|
|
2005
|
+
* - Request/response interceptors
|
|
2006
|
+
* - Timeout support
|
|
2007
|
+
* - Base URL configuration
|
|
2008
|
+
* - Abort controller integration
|
|
2009
|
+
*
|
|
2010
|
+
* Usage:
|
|
2011
|
+
* $.http.get('/api/users');
|
|
2012
|
+
* $.http.post('/api/users', { name: 'Tony' });
|
|
2013
|
+
* $.http.configure({ baseURL: 'https://api.example.com', headers: { Authorization: 'Bearer ...' } });
|
|
2014
|
+
*/
|
|
2015
|
+
|
|
2016
|
+
const _config = {
|
|
2017
|
+
baseURL: '',
|
|
2018
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2019
|
+
timeout: 30000,
|
|
2020
|
+
};
|
|
2021
|
+
|
|
2022
|
+
const _interceptors = {
|
|
2023
|
+
request: [],
|
|
2024
|
+
response: [],
|
|
2025
|
+
};
|
|
2026
|
+
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* Core request function
|
|
2030
|
+
*/
|
|
2031
|
+
async function request(method, url, data, options = {}) {
|
|
2032
|
+
let fullURL = url.startsWith('http') ? url : _config.baseURL + url;
|
|
2033
|
+
let headers = { ..._config.headers, ...options.headers };
|
|
2034
|
+
let body = undefined;
|
|
2035
|
+
|
|
2036
|
+
// Build fetch options
|
|
2037
|
+
const fetchOpts = {
|
|
2038
|
+
method: method.toUpperCase(),
|
|
2039
|
+
headers,
|
|
2040
|
+
...options,
|
|
2041
|
+
};
|
|
2042
|
+
|
|
2043
|
+
// Handle body
|
|
2044
|
+
if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
|
|
2045
|
+
if (data instanceof FormData) {
|
|
2046
|
+
body = data;
|
|
2047
|
+
delete fetchOpts.headers['Content-Type']; // Let browser set multipart boundary
|
|
2048
|
+
} else if (typeof data === 'object') {
|
|
2049
|
+
body = JSON.stringify(data);
|
|
2050
|
+
} else {
|
|
2051
|
+
body = data;
|
|
2052
|
+
}
|
|
2053
|
+
fetchOpts.body = body;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
// Query params for GET
|
|
2057
|
+
if (data && (method === 'GET' || method === 'HEAD') && typeof data === 'object') {
|
|
2058
|
+
const params = new URLSearchParams(data).toString();
|
|
2059
|
+
fullURL += (fullURL.includes('?') ? '&' : '?') + params;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// Timeout via AbortController
|
|
2063
|
+
const controller = new AbortController();
|
|
2064
|
+
fetchOpts.signal = options.signal || controller.signal;
|
|
2065
|
+
const timeout = options.timeout ?? _config.timeout;
|
|
2066
|
+
let timer;
|
|
2067
|
+
if (timeout > 0) {
|
|
2068
|
+
timer = setTimeout(() => controller.abort(), timeout);
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// Run request interceptors
|
|
2072
|
+
for (const interceptor of _interceptors.request) {
|
|
2073
|
+
const result = await interceptor(fetchOpts, fullURL);
|
|
2074
|
+
if (result === false) throw new Error('Request blocked by interceptor');
|
|
2075
|
+
if (result?.url) fullURL = result.url;
|
|
2076
|
+
if (result?.options) Object.assign(fetchOpts, result.options);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
try {
|
|
2080
|
+
const response = await fetch(fullURL, fetchOpts);
|
|
2081
|
+
if (timer) clearTimeout(timer);
|
|
2082
|
+
|
|
2083
|
+
// Parse response
|
|
2084
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
2085
|
+
let responseData;
|
|
2086
|
+
|
|
2087
|
+
if (contentType.includes('application/json')) {
|
|
2088
|
+
responseData = await response.json();
|
|
2089
|
+
} else if (contentType.includes('text/')) {
|
|
2090
|
+
responseData = await response.text();
|
|
2091
|
+
} else if (contentType.includes('application/octet-stream') || contentType.includes('image/')) {
|
|
2092
|
+
responseData = await response.blob();
|
|
2093
|
+
} else {
|
|
2094
|
+
// Try JSON first, fall back to text
|
|
2095
|
+
const text = await response.text();
|
|
2096
|
+
try { responseData = JSON.parse(text); } catch { responseData = text; }
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
const result = {
|
|
2100
|
+
ok: response.ok,
|
|
2101
|
+
status: response.status,
|
|
2102
|
+
statusText: response.statusText,
|
|
2103
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
2104
|
+
data: responseData,
|
|
2105
|
+
response,
|
|
2106
|
+
};
|
|
2107
|
+
|
|
2108
|
+
// Run response interceptors
|
|
2109
|
+
for (const interceptor of _interceptors.response) {
|
|
2110
|
+
await interceptor(result);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
if (!response.ok) {
|
|
2114
|
+
const err = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
2115
|
+
err.response = result;
|
|
2116
|
+
throw err;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
return result;
|
|
2120
|
+
} catch (err) {
|
|
2121
|
+
if (timer) clearTimeout(timer);
|
|
2122
|
+
if (err.name === 'AbortError') {
|
|
2123
|
+
throw new Error(`Request timeout after ${timeout}ms: ${method} ${fullURL}`);
|
|
2124
|
+
}
|
|
2125
|
+
throw err;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
// ---------------------------------------------------------------------------
|
|
2131
|
+
// Public API
|
|
2132
|
+
// ---------------------------------------------------------------------------
|
|
2133
|
+
const http = {
|
|
2134
|
+
get: (url, params, opts) => request('GET', url, params, opts),
|
|
2135
|
+
post: (url, data, opts) => request('POST', url, data, opts),
|
|
2136
|
+
put: (url, data, opts) => request('PUT', url, data, opts),
|
|
2137
|
+
patch: (url, data, opts) => request('PATCH', url, data, opts),
|
|
2138
|
+
delete: (url, data, opts) => request('DELETE', url, data, opts),
|
|
2139
|
+
|
|
2140
|
+
/**
|
|
2141
|
+
* Configure defaults
|
|
2142
|
+
*/
|
|
2143
|
+
configure(opts) {
|
|
2144
|
+
if (opts.baseURL !== undefined) _config.baseURL = opts.baseURL;
|
|
2145
|
+
if (opts.headers) Object.assign(_config.headers, opts.headers);
|
|
2146
|
+
if (opts.timeout !== undefined) _config.timeout = opts.timeout;
|
|
2147
|
+
},
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* Add request interceptor
|
|
2151
|
+
* @param {Function} fn — (fetchOpts, url) → void | false | { url, options }
|
|
2152
|
+
*/
|
|
2153
|
+
onRequest(fn) {
|
|
2154
|
+
_interceptors.request.push(fn);
|
|
2155
|
+
},
|
|
2156
|
+
|
|
2157
|
+
/**
|
|
2158
|
+
* Add response interceptor
|
|
2159
|
+
* @param {Function} fn — (result) → void
|
|
2160
|
+
*/
|
|
2161
|
+
onResponse(fn) {
|
|
2162
|
+
_interceptors.response.push(fn);
|
|
2163
|
+
},
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* Create a standalone AbortController for manual cancellation
|
|
2167
|
+
*/
|
|
2168
|
+
createAbort() {
|
|
2169
|
+
return new AbortController();
|
|
2170
|
+
},
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* Raw fetch pass-through (for edge cases)
|
|
2174
|
+
*/
|
|
2175
|
+
raw: (url, opts) => fetch(url, opts),
|
|
2176
|
+
};
|
|
2177
|
+
|
|
2178
|
+
// --- src/utils.js ————————————————————————————————————————————————
|
|
2179
|
+
/**
|
|
2180
|
+
* zQuery Utils — Common utility functions
|
|
2181
|
+
*
|
|
2182
|
+
* Quality-of-life helpers that every frontend project needs.
|
|
2183
|
+
* Attached to $ namespace for convenience.
|
|
2184
|
+
*/
|
|
2185
|
+
|
|
2186
|
+
// ---------------------------------------------------------------------------
|
|
2187
|
+
// Function utilities
|
|
2188
|
+
// ---------------------------------------------------------------------------
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* Debounce — delays execution until after `ms` of inactivity
|
|
2192
|
+
*/
|
|
2193
|
+
function debounce(fn, ms = 250) {
|
|
2194
|
+
let timer;
|
|
2195
|
+
const debounced = (...args) => {
|
|
2196
|
+
clearTimeout(timer);
|
|
2197
|
+
timer = setTimeout(() => fn(...args), ms);
|
|
2198
|
+
};
|
|
2199
|
+
debounced.cancel = () => clearTimeout(timer);
|
|
2200
|
+
return debounced;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* Throttle — limits execution to once per `ms`
|
|
2205
|
+
*/
|
|
2206
|
+
function throttle(fn, ms = 250) {
|
|
2207
|
+
let last = 0;
|
|
2208
|
+
let timer;
|
|
2209
|
+
return (...args) => {
|
|
2210
|
+
const now = Date.now();
|
|
2211
|
+
const remaining = ms - (now - last);
|
|
2212
|
+
clearTimeout(timer);
|
|
2213
|
+
if (remaining <= 0) {
|
|
2214
|
+
last = now;
|
|
2215
|
+
fn(...args);
|
|
2216
|
+
} else {
|
|
2217
|
+
timer = setTimeout(() => { last = Date.now(); fn(...args); }, remaining);
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* Pipe — compose functions left-to-right
|
|
2224
|
+
*/
|
|
2225
|
+
function pipe(...fns) {
|
|
2226
|
+
return (input) => fns.reduce((val, fn) => fn(val), input);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/**
|
|
2230
|
+
* Once — function that only runs once
|
|
2231
|
+
*/
|
|
2232
|
+
function once(fn) {
|
|
2233
|
+
let called = false, result;
|
|
2234
|
+
return (...args) => {
|
|
2235
|
+
if (!called) { called = true; result = fn(...args); }
|
|
2236
|
+
return result;
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
/**
|
|
2241
|
+
* Sleep — promise-based delay
|
|
2242
|
+
*/
|
|
2243
|
+
function sleep(ms) {
|
|
2244
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
|
|
2248
|
+
// ---------------------------------------------------------------------------
|
|
2249
|
+
// String utilities
|
|
2250
|
+
// ---------------------------------------------------------------------------
|
|
2251
|
+
|
|
2252
|
+
/**
|
|
2253
|
+
* Escape HTML entities
|
|
2254
|
+
*/
|
|
2255
|
+
function escapeHtml(str) {
|
|
2256
|
+
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
2257
|
+
return String(str).replace(/[&<>"']/g, c => map[c]);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/**
|
|
2261
|
+
* Template tag for auto-escaping interpolated values
|
|
2262
|
+
* Usage: $.html`<div>${userInput}</div>`
|
|
2263
|
+
*/
|
|
2264
|
+
function html(strings, ...values) {
|
|
2265
|
+
return strings.reduce((result, str, i) => {
|
|
2266
|
+
const val = values[i - 1];
|
|
2267
|
+
const escaped = (val instanceof TrustedHTML) ? val.toString() : escapeHtml(val ?? '');
|
|
2268
|
+
return result + escaped + str;
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/**
|
|
2273
|
+
* Mark HTML as trusted (skip escaping in $.html template)
|
|
2274
|
+
*/
|
|
2275
|
+
class TrustedHTML {
|
|
2276
|
+
constructor(html) { this._html = html; }
|
|
2277
|
+
toString() { return this._html; }
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
function trust(htmlStr) {
|
|
2281
|
+
return new TrustedHTML(htmlStr);
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
/**
|
|
2285
|
+
* Generate UUID v4
|
|
2286
|
+
*/
|
|
2287
|
+
function uuid() {
|
|
2288
|
+
return crypto?.randomUUID?.() || 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
2289
|
+
const r = Math.random() * 16 | 0;
|
|
2290
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
/**
|
|
2295
|
+
* Kebab-case to camelCase
|
|
2296
|
+
*/
|
|
2297
|
+
function camelCase(str) {
|
|
2298
|
+
return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/**
|
|
2302
|
+
* CamelCase to kebab-case
|
|
2303
|
+
*/
|
|
2304
|
+
function kebabCase(str) {
|
|
2305
|
+
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
|
|
2309
|
+
// ---------------------------------------------------------------------------
|
|
2310
|
+
// Object utilities
|
|
2311
|
+
// ---------------------------------------------------------------------------
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* Deep clone
|
|
2315
|
+
*/
|
|
2316
|
+
function deepClone(obj) {
|
|
2317
|
+
if (typeof structuredClone === 'function') return structuredClone(obj);
|
|
2318
|
+
return JSON.parse(JSON.stringify(obj));
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* Deep merge objects
|
|
2323
|
+
*/
|
|
2324
|
+
function deepMerge(target, ...sources) {
|
|
2325
|
+
for (const source of sources) {
|
|
2326
|
+
for (const key of Object.keys(source)) {
|
|
2327
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
2328
|
+
if (!target[key] || typeof target[key] !== 'object') target[key] = {};
|
|
2329
|
+
deepMerge(target[key], source[key]);
|
|
2330
|
+
} else {
|
|
2331
|
+
target[key] = source[key];
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
return target;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
/**
|
|
2339
|
+
* Simple object equality check
|
|
2340
|
+
*/
|
|
2341
|
+
function isEqual(a, b) {
|
|
2342
|
+
if (a === b) return true;
|
|
2343
|
+
if (typeof a !== typeof b) return false;
|
|
2344
|
+
if (typeof a !== 'object' || a === null || b === null) return false;
|
|
2345
|
+
const keysA = Object.keys(a);
|
|
2346
|
+
const keysB = Object.keys(b);
|
|
2347
|
+
if (keysA.length !== keysB.length) return false;
|
|
2348
|
+
return keysA.every(k => isEqual(a[k], b[k]));
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
|
|
2352
|
+
// ---------------------------------------------------------------------------
|
|
2353
|
+
// URL utilities
|
|
2354
|
+
// ---------------------------------------------------------------------------
|
|
2355
|
+
|
|
2356
|
+
/**
|
|
2357
|
+
* Serialize object to URL query string
|
|
2358
|
+
*/
|
|
2359
|
+
function param(obj) {
|
|
2360
|
+
return new URLSearchParams(obj).toString();
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/**
|
|
2364
|
+
* Parse URL query string to object
|
|
2365
|
+
*/
|
|
2366
|
+
function parseQuery(str) {
|
|
2367
|
+
return Object.fromEntries(new URLSearchParams(str));
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
// ---------------------------------------------------------------------------
|
|
2372
|
+
// Storage helpers (localStorage wrapper with JSON support)
|
|
2373
|
+
// ---------------------------------------------------------------------------
|
|
2374
|
+
const storage = {
|
|
2375
|
+
get(key, fallback = null) {
|
|
2376
|
+
try {
|
|
2377
|
+
const raw = localStorage.getItem(key);
|
|
2378
|
+
return raw !== null ? JSON.parse(raw) : fallback;
|
|
2379
|
+
} catch {
|
|
2380
|
+
return fallback;
|
|
2381
|
+
}
|
|
2382
|
+
},
|
|
2383
|
+
|
|
2384
|
+
set(key, value) {
|
|
2385
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
2386
|
+
},
|
|
2387
|
+
|
|
2388
|
+
remove(key) {
|
|
2389
|
+
localStorage.removeItem(key);
|
|
2390
|
+
},
|
|
2391
|
+
|
|
2392
|
+
clear() {
|
|
2393
|
+
localStorage.clear();
|
|
2394
|
+
},
|
|
2395
|
+
};
|
|
2396
|
+
|
|
2397
|
+
const session = {
|
|
2398
|
+
get(key, fallback = null) {
|
|
2399
|
+
try {
|
|
2400
|
+
const raw = sessionStorage.getItem(key);
|
|
2401
|
+
return raw !== null ? JSON.parse(raw) : fallback;
|
|
2402
|
+
} catch {
|
|
2403
|
+
return fallback;
|
|
2404
|
+
}
|
|
2405
|
+
},
|
|
2406
|
+
|
|
2407
|
+
set(key, value) {
|
|
2408
|
+
sessionStorage.setItem(key, JSON.stringify(value));
|
|
2409
|
+
},
|
|
2410
|
+
|
|
2411
|
+
remove(key) {
|
|
2412
|
+
sessionStorage.removeItem(key);
|
|
2413
|
+
},
|
|
2414
|
+
|
|
2415
|
+
clear() {
|
|
2416
|
+
sessionStorage.clear();
|
|
2417
|
+
},
|
|
2418
|
+
};
|
|
2419
|
+
|
|
2420
|
+
|
|
2421
|
+
// ---------------------------------------------------------------------------
|
|
2422
|
+
// Event bus (pub/sub)
|
|
2423
|
+
// ---------------------------------------------------------------------------
|
|
2424
|
+
class EventBus {
|
|
2425
|
+
constructor() { this._handlers = new Map(); }
|
|
2426
|
+
|
|
2427
|
+
on(event, fn) {
|
|
2428
|
+
if (!this._handlers.has(event)) this._handlers.set(event, new Set());
|
|
2429
|
+
this._handlers.get(event).add(fn);
|
|
2430
|
+
return () => this.off(event, fn);
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
off(event, fn) {
|
|
2434
|
+
this._handlers.get(event)?.delete(fn);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
emit(event, ...args) {
|
|
2438
|
+
this._handlers.get(event)?.forEach(fn => fn(...args));
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
once(event, fn) {
|
|
2442
|
+
const wrapper = (...args) => { fn(...args); this.off(event, wrapper); };
|
|
2443
|
+
return this.on(event, wrapper);
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
clear() { this._handlers.clear(); }
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
const bus = new EventBus();
|
|
2450
|
+
|
|
2451
|
+
// --- index.js (assembly) ——————————————————————————————————————————
|
|
2452
|
+
/**
|
|
2453
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
2454
|
+
* │ zQuery (zeroQuery) — Lightweight Frontend Library │
|
|
2455
|
+
* │ │
|
|
2456
|
+
* │ jQuery-like selectors · Reactive components │
|
|
2457
|
+
* │ SPA router · State management · Zero dependencies │
|
|
2458
|
+
* │ │
|
|
2459
|
+
* │ https://github.com/tonywied17/zero-query │
|
|
2460
|
+
* └─────────────────────────────────────────────────────────┘
|
|
2461
|
+
*/
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
|
|
2467
|
+
|
|
2468
|
+
|
|
2469
|
+
|
|
2470
|
+
// ---------------------------------------------------------------------------
|
|
2471
|
+
// $ — The main function & namespace
|
|
2472
|
+
// ---------------------------------------------------------------------------
|
|
2473
|
+
|
|
2474
|
+
/**
|
|
2475
|
+
* Main selector function
|
|
2476
|
+
*
|
|
2477
|
+
* $('selector') → single Element (querySelector)
|
|
2478
|
+
* $('<div>hello</div>') → create element (first created node)
|
|
2479
|
+
* $(element) → return element as-is
|
|
2480
|
+
* $(fn) → DOMContentLoaded shorthand
|
|
2481
|
+
*
|
|
2482
|
+
* @param {string|Element|NodeList|Function} selector
|
|
2483
|
+
* @param {string|Element} [context]
|
|
2484
|
+
* @returns {Element|null}
|
|
2485
|
+
*/
|
|
2486
|
+
function $(selector, context) {
|
|
2487
|
+
// $(fn) → DOM ready shorthand
|
|
2488
|
+
if (typeof selector === 'function') {
|
|
2489
|
+
query.ready(selector);
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
return query(selector, context);
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
|
|
2496
|
+
// --- Quick refs ------------------------------------------------------------
|
|
2497
|
+
$.id = query.id;
|
|
2498
|
+
$.class = query.class;
|
|
2499
|
+
$.classes = query.classes;
|
|
2500
|
+
$.tag = query.tag;
|
|
2501
|
+
$.children = query.children;
|
|
2502
|
+
|
|
2503
|
+
// --- Collection selector ---------------------------------------------------
|
|
2504
|
+
/**
|
|
2505
|
+
* Collection selector (like jQuery's $)
|
|
2506
|
+
*
|
|
2507
|
+
* $.all('selector') → ZQueryCollection (querySelectorAll)
|
|
2508
|
+
* $.all('<div>hello</div>') → create elements as collection
|
|
2509
|
+
* $.all(element) → wrap element in collection
|
|
2510
|
+
* $.all(nodeList) → wrap NodeList in collection
|
|
2511
|
+
*
|
|
2512
|
+
* @param {string|Element|NodeList|Array} selector
|
|
2513
|
+
* @param {string|Element} [context]
|
|
2514
|
+
* @returns {ZQueryCollection}
|
|
2515
|
+
*/
|
|
2516
|
+
$.all = function(selector, context) {
|
|
2517
|
+
return queryAll(selector, context);
|
|
2518
|
+
};
|
|
2519
|
+
|
|
2520
|
+
// --- DOM helpers -----------------------------------------------------------
|
|
2521
|
+
$.create = query.create;
|
|
2522
|
+
$.ready = query.ready;
|
|
2523
|
+
$.on = query.on;
|
|
2524
|
+
$.fn = query.fn;
|
|
2525
|
+
|
|
2526
|
+
// --- Reactive primitives ---------------------------------------------------
|
|
2527
|
+
$.reactive = reactive;
|
|
2528
|
+
$.signal = signal;
|
|
2529
|
+
$.computed = computed;
|
|
2530
|
+
$.effect = effect;
|
|
2531
|
+
|
|
2532
|
+
// --- Components ------------------------------------------------------------
|
|
2533
|
+
$.component = component;
|
|
2534
|
+
$.mount = mount;
|
|
2535
|
+
$.mountAll = mountAll;
|
|
2536
|
+
$.getInstance = getInstance;
|
|
2537
|
+
$.destroy = destroy;
|
|
2538
|
+
$.components = getRegistry;
|
|
2539
|
+
$.style = style;
|
|
2540
|
+
|
|
2541
|
+
// --- Router ----------------------------------------------------------------
|
|
2542
|
+
$.router = createRouter;
|
|
2543
|
+
$.getRouter = getRouter;
|
|
2544
|
+
|
|
2545
|
+
// --- Store -----------------------------------------------------------------
|
|
2546
|
+
$.store = createStore;
|
|
2547
|
+
$.getStore = getStore;
|
|
2548
|
+
|
|
2549
|
+
// --- HTTP ------------------------------------------------------------------
|
|
2550
|
+
$.http = http;
|
|
2551
|
+
$.get = http.get;
|
|
2552
|
+
$.post = http.post;
|
|
2553
|
+
$.put = http.put;
|
|
2554
|
+
$.patch = http.patch;
|
|
2555
|
+
$.delete = http.delete;
|
|
2556
|
+
|
|
2557
|
+
// --- Utilities -------------------------------------------------------------
|
|
2558
|
+
$.debounce = debounce;
|
|
2559
|
+
$.throttle = throttle;
|
|
2560
|
+
$.pipe = pipe;
|
|
2561
|
+
$.once = once;
|
|
2562
|
+
$.sleep = sleep;
|
|
2563
|
+
$.escapeHtml = escapeHtml;
|
|
2564
|
+
$.html = html;
|
|
2565
|
+
$.trust = trust;
|
|
2566
|
+
$.uuid = uuid;
|
|
2567
|
+
$.camelCase = camelCase;
|
|
2568
|
+
$.kebabCase = kebabCase;
|
|
2569
|
+
$.deepClone = deepClone;
|
|
2570
|
+
$.deepMerge = deepMerge;
|
|
2571
|
+
$.isEqual = isEqual;
|
|
2572
|
+
$.param = param;
|
|
2573
|
+
$.parseQuery = parseQuery;
|
|
2574
|
+
$.storage = storage;
|
|
2575
|
+
$.session = session;
|
|
2576
|
+
$.bus = bus;
|
|
2577
|
+
|
|
2578
|
+
// --- Meta ------------------------------------------------------------------
|
|
2579
|
+
$.version = '0.2.3';
|
|
2580
|
+
$.meta = {}; // populated at build time by CLI bundler
|
|
2581
|
+
|
|
2582
|
+
$.noConflict = () => {
|
|
2583
|
+
if (typeof window !== 'undefined' && window.$ === $) {
|
|
2584
|
+
delete window.$;
|
|
2585
|
+
}
|
|
2586
|
+
return $;
|
|
2587
|
+
};
|
|
2588
|
+
|
|
2589
|
+
|
|
2590
|
+
// ---------------------------------------------------------------------------
|
|
2591
|
+
// Global exposure (browser)
|
|
2592
|
+
// ---------------------------------------------------------------------------
|
|
2593
|
+
if (typeof window !== 'undefined') {
|
|
2594
|
+
window.$ = $;
|
|
2595
|
+
window.zQuery = $;
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
|
|
2599
|
+
// ---------------------------------------------------------------------------
|
|
2600
|
+
// Named exports (ES modules)
|
|
2601
|
+
// ---------------------------------------------------------------------------
|
|
2602
|
+
|
|
2603
|
+
$;
|
|
2604
|
+
|
|
2605
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|