zero-query 0.6.3 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +39 -29
  2. package/cli/commands/build.js +113 -4
  3. package/cli/commands/bundle.js +392 -29
  4. package/cli/commands/create.js +1 -1
  5. package/cli/commands/dev/devtools/index.js +56 -0
  6. package/cli/commands/dev/devtools/js/components.js +49 -0
  7. package/cli/commands/dev/devtools/js/core.js +409 -0
  8. package/cli/commands/dev/devtools/js/elements.js +413 -0
  9. package/cli/commands/dev/devtools/js/network.js +166 -0
  10. package/cli/commands/dev/devtools/js/performance.js +73 -0
  11. package/cli/commands/dev/devtools/js/router.js +105 -0
  12. package/cli/commands/dev/devtools/js/source.js +132 -0
  13. package/cli/commands/dev/devtools/js/stats.js +35 -0
  14. package/cli/commands/dev/devtools/js/tabs.js +79 -0
  15. package/cli/commands/dev/devtools/panel.html +95 -0
  16. package/cli/commands/dev/devtools/styles.css +244 -0
  17. package/cli/commands/dev/index.js +29 -4
  18. package/cli/commands/dev/logger.js +6 -1
  19. package/cli/commands/dev/overlay.js +428 -2
  20. package/cli/commands/dev/server.js +42 -5
  21. package/cli/commands/dev/watcher.js +59 -1
  22. package/cli/help.js +8 -5
  23. package/cli/scaffold/{scripts → app}/app.js +16 -23
  24. package/cli/scaffold/{scripts → app}/components/about.js +4 -4
  25. package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
  26. package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -7
  27. package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +3 -3
  28. package/cli/scaffold/app/components/home.js +137 -0
  29. package/cli/scaffold/{scripts → app}/routes.js +1 -1
  30. package/cli/scaffold/{scripts → app}/store.js +6 -6
  31. package/cli/scaffold/assets/.gitkeep +0 -0
  32. package/cli/scaffold/{styles/styles.css → global.css} +4 -2
  33. package/cli/scaffold/index.html +12 -11
  34. package/cli/utils.js +111 -6
  35. package/dist/zquery.dist.zip +0 -0
  36. package/dist/zquery.js +1122 -158
  37. package/dist/zquery.min.js +3 -16
  38. package/index.d.ts +129 -1290
  39. package/index.js +15 -10
  40. package/package.json +7 -6
  41. package/src/component.js +172 -49
  42. package/src/core.js +359 -18
  43. package/src/diff.js +256 -58
  44. package/src/expression.js +33 -3
  45. package/src/reactive.js +37 -5
  46. package/src/router.js +243 -7
  47. package/tests/component.test.js +886 -0
  48. package/tests/core.test.js +977 -0
  49. package/tests/diff.test.js +525 -0
  50. package/tests/errors.test.js +162 -0
  51. package/tests/expression.test.js +482 -0
  52. package/tests/http.test.js +289 -0
  53. package/tests/reactive.test.js +339 -0
  54. package/tests/router.test.js +649 -0
  55. package/tests/store.test.js +379 -0
  56. package/tests/utils.test.js +512 -0
  57. package/types/collection.d.ts +383 -0
  58. package/types/component.d.ts +217 -0
  59. package/types/errors.d.ts +103 -0
  60. package/types/http.d.ts +81 -0
  61. package/types/misc.d.ts +179 -0
  62. package/types/reactive.d.ts +76 -0
  63. package/types/router.d.ts +161 -0
  64. package/types/ssr.d.ts +49 -0
  65. package/types/store.d.ts +107 -0
  66. package/types/utils.d.ts +142 -0
  67. package/cli/commands/dev.old.js +0 -520
  68. package/cli/scaffold/scripts/components/home.js +0 -137
  69. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  70. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  71. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  72. /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
@@ -0,0 +1,886 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { component, mount, mountAll, getInstance, destroy, getRegistry } from '../src/component.js';
3
+ import { ZQueryError } from '../src/errors.js';
4
+
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Setup
8
+ // ---------------------------------------------------------------------------
9
+ beforeEach(() => {
10
+ document.body.innerHTML = '';
11
+ });
12
+
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Component registration
16
+ // ---------------------------------------------------------------------------
17
+
18
+ describe('component() — registration', () => {
19
+ it('registers a component', () => {
20
+ component('test-comp', {
21
+ state: () => ({ count: 0 }),
22
+ render() { return `<p>${this.state.count}</p>`; },
23
+ });
24
+ const registry = getRegistry();
25
+ expect(registry['test-comp']).toBeDefined();
26
+ });
27
+
28
+ it('throws ZQueryError if name has no hyphen', () => {
29
+ expect(() => component('nohyphen', {})).toThrow(ZQueryError);
30
+ });
31
+
32
+ it('throws ZQueryError if name is empty', () => {
33
+ expect(() => component('', {})).toThrow(ZQueryError);
34
+ });
35
+
36
+ it('throws ZQueryError if name is not a string', () => {
37
+ expect(() => component(null, {})).toThrow(ZQueryError);
38
+ });
39
+ });
40
+
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Mount
44
+ // ---------------------------------------------------------------------------
45
+
46
+ describe('mount()', () => {
47
+ beforeEach(() => {
48
+ component('mount-test', {
49
+ state: () => ({ msg: 'Hello' }),
50
+ render() { return `<div class="inner">${this.state.msg}</div>`; },
51
+ });
52
+ document.body.innerHTML = '<mount-test id="target"></mount-test>';
53
+ });
54
+
55
+ it('mounts component and renders HTML', () => {
56
+ const instance = mount('#target', 'mount-test');
57
+ expect(document.querySelector('.inner').textContent).toBe('Hello');
58
+ expect(instance).toBeDefined();
59
+ });
60
+
61
+ it('throws ZQueryError for missing target', () => {
62
+ expect(() => mount('#nonexistent', 'mount-test')).toThrow(ZQueryError);
63
+ });
64
+
65
+ it('throws ZQueryError for unregistered component', () => {
66
+ expect(() => mount('#target', 'unknown-comp')).toThrow(ZQueryError);
67
+ });
68
+
69
+ it('getInstance returns instance after mount', () => {
70
+ mount('#target', 'mount-test');
71
+ const inst = getInstance('#target');
72
+ expect(inst).not.toBeNull();
73
+ expect(inst.state.msg).toBe('Hello');
74
+ });
75
+ });
76
+
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Lifecycle hooks
80
+ // ---------------------------------------------------------------------------
81
+
82
+ describe('component — lifecycle', () => {
83
+ it('calls init on creation', () => {
84
+ const initFn = vi.fn();
85
+ component('life-init', {
86
+ init: initFn,
87
+ render() { return '<div>init</div>'; },
88
+ });
89
+ document.body.innerHTML = '<life-init id="li"></life-init>';
90
+ mount('#li', 'life-init');
91
+ expect(initFn).toHaveBeenCalledOnce();
92
+ });
93
+
94
+ it('calls mounted after first render', () => {
95
+ const mountedFn = vi.fn();
96
+ component('life-mounted', {
97
+ mounted: mountedFn,
98
+ render() { return '<div>mounted</div>'; },
99
+ });
100
+ document.body.innerHTML = '<life-mounted id="lm"></life-mounted>';
101
+ mount('#lm', 'life-mounted');
102
+ expect(mountedFn).toHaveBeenCalledOnce();
103
+ });
104
+
105
+ it('calls destroyed on destroy', () => {
106
+ const destroyedFn = vi.fn();
107
+ component('life-destroy', {
108
+ destroyed: destroyedFn,
109
+ render() { return '<div>destroy</div>'; },
110
+ });
111
+ document.body.innerHTML = '<life-destroy id="ld"></life-destroy>';
112
+ mount('#ld', 'life-destroy');
113
+ destroy('#ld');
114
+ expect(destroyedFn).toHaveBeenCalledOnce();
115
+ });
116
+
117
+ it('does not crash when lifecycle hook throws', () => {
118
+ component('life-throw', {
119
+ init() { throw new Error('init error'); },
120
+ render() { return '<div>throw</div>'; },
121
+ });
122
+ document.body.innerHTML = '<life-throw id="lt"></life-throw>';
123
+ expect(() => mount('#lt', 'life-throw')).not.toThrow();
124
+ });
125
+
126
+ it('calls updated on re-render', async () => {
127
+ const updatedFn = vi.fn();
128
+ component('life-update', {
129
+ state: () => ({ n: 0 }),
130
+ updated: updatedFn,
131
+ render() { return `<div>${this.state.n}</div>`; },
132
+ });
133
+ document.body.innerHTML = '<life-update id="lu"></life-update>';
134
+ const inst = mount('#lu', 'life-update');
135
+ expect(updatedFn).not.toHaveBeenCalled();
136
+ inst.state.n = 1;
137
+ await new Promise(r => queueMicrotask(r));
138
+ expect(updatedFn).toHaveBeenCalledOnce();
139
+ });
140
+ });
141
+
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Reactive state
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe('component — reactive state', () => {
148
+ it('re-renders on state change', async () => {
149
+ component('react-state', {
150
+ state: () => ({ count: 0 }),
151
+ render() { return `<span class="count">${this.state.count}</span>`; },
152
+ });
153
+ document.body.innerHTML = '<react-state id="rs"></react-state>';
154
+ const inst = mount('#rs', 'react-state');
155
+ expect(document.querySelector('.count').textContent).toBe('0');
156
+
157
+ inst.state.count = 5;
158
+ // State update is batched via microtask
159
+ await new Promise(r => queueMicrotask(r));
160
+ expect(document.querySelector('.count').textContent).toBe('5');
161
+ });
162
+
163
+ it('batches multiple state changes into one render', async () => {
164
+ const renderSpy = vi.fn();
165
+ component('batch-state', {
166
+ state: () => ({ a: 0, b: 0 }),
167
+ render() {
168
+ renderSpy();
169
+ return `<div>${this.state.a}-${this.state.b}</div>`;
170
+ },
171
+ });
172
+ document.body.innerHTML = '<batch-state id="bs"></batch-state>';
173
+ const inst = mount('#bs', 'batch-state');
174
+ renderSpy.mockClear();
175
+
176
+ inst.state.a = 1;
177
+ inst.state.b = 2;
178
+ await new Promise(r => queueMicrotask(r));
179
+ // Should only render once despite two state changes
180
+ expect(renderSpy).toHaveBeenCalledOnce();
181
+ });
182
+ });
183
+
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Props
187
+ // ---------------------------------------------------------------------------
188
+
189
+ describe('component — props', () => {
190
+ it('receives props', () => {
191
+ component('prop-test', {
192
+ render() { return `<span class="prop">${this.props.label}</span>`; },
193
+ });
194
+ document.body.innerHTML = '<prop-test id="pt"></prop-test>';
195
+ mount('#pt', 'prop-test', { label: 'Hello' });
196
+ expect(document.querySelector('.prop').textContent).toBe('Hello');
197
+ });
198
+
199
+ it('props are frozen', () => {
200
+ component('prop-freeze', {
201
+ render() { return '<div>test</div>'; },
202
+ });
203
+ document.body.innerHTML = '<prop-freeze id="pf"></prop-freeze>';
204
+ const inst = mount('#pf', 'prop-freeze', { x: 1 });
205
+ expect(() => { inst.props.x = 2; }).toThrow();
206
+ });
207
+ });
208
+
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Computed properties
212
+ // ---------------------------------------------------------------------------
213
+
214
+ describe('component — computed', () => {
215
+ it('derives values from state', () => {
216
+ component('comp-computed', {
217
+ state: () => ({ count: 5 }),
218
+ computed: {
219
+ doubled(state) { return state.count * 2; },
220
+ },
221
+ render() { return `<span class="doubled">${this.computed.doubled}</span>`; },
222
+ });
223
+ document.body.innerHTML = '<comp-computed id="cc"></comp-computed>';
224
+ const inst = mount('#cc', 'comp-computed');
225
+ expect(inst.computed.doubled).toBe(10);
226
+ expect(document.querySelector('.doubled').textContent).toBe('10');
227
+ });
228
+
229
+ it('recomputes when state changes', async () => {
230
+ component('comp-recompute', {
231
+ state: () => ({ val: 3 }),
232
+ computed: {
233
+ tripled(state) { return state.val * 3; },
234
+ },
235
+ render() { return `<span class="tri">${this.computed.tripled}</span>`; },
236
+ });
237
+ document.body.innerHTML = '<comp-recompute id="cr"></comp-recompute>';
238
+ const inst = mount('#cr', 'comp-recompute');
239
+ expect(inst.computed.tripled).toBe(9);
240
+ inst.state.val = 10;
241
+ await new Promise(r => queueMicrotask(r));
242
+ expect(inst.computed.tripled).toBe(30);
243
+ });
244
+ });
245
+
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // User methods
249
+ // ---------------------------------------------------------------------------
250
+
251
+ describe('component — methods', () => {
252
+ it('binds user methods to instance', () => {
253
+ let captured;
254
+ component('method-test', {
255
+ state: () => ({ x: 42 }),
256
+ myMethod() { captured = this.state.x; },
257
+ render() { return '<div>methods</div>'; },
258
+ });
259
+ document.body.innerHTML = '<method-test id="mt"></method-test>';
260
+ const inst = mount('#mt', 'method-test');
261
+ inst.myMethod();
262
+ expect(captured).toBe(42);
263
+ });
264
+ });
265
+
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // setState
269
+ // ---------------------------------------------------------------------------
270
+
271
+ describe('component — setState', () => {
272
+ it('batch updates state', async () => {
273
+ component('set-state', {
274
+ state: () => ({ a: 1, b: 2 }),
275
+ render() { return `<div>${this.state.a}-${this.state.b}</div>`; },
276
+ });
277
+ document.body.innerHTML = '<set-state id="ss"></set-state>';
278
+ const inst = mount('#ss', 'set-state');
279
+ inst.setState({ a: 10, b: 20 });
280
+ await new Promise(r => queueMicrotask(r));
281
+ expect(inst.state.a).toBe(10);
282
+ expect(inst.state.b).toBe(20);
283
+ });
284
+
285
+ it('forces re-render with empty setState', async () => {
286
+ let renderCount = 0;
287
+ component('force-render', {
288
+ state: () => ({ x: 1 }),
289
+ render() { renderCount++; return `<div>${this.state.x}</div>`; },
290
+ });
291
+ document.body.innerHTML = '<force-render id="fr"></force-render>';
292
+ const inst = mount('#fr', 'force-render');
293
+ renderCount = 0;
294
+ inst.setState({});
295
+ await new Promise(r => queueMicrotask(r));
296
+ expect(renderCount).toBe(1);
297
+ });
298
+ });
299
+
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // emit
303
+ // ---------------------------------------------------------------------------
304
+
305
+ describe('component — emit', () => {
306
+ it('dispatches custom event', () => {
307
+ component('emit-test', {
308
+ render() { return '<div>emit</div>'; },
309
+ });
310
+ document.body.innerHTML = '<emit-test id="et"></emit-test>';
311
+ const inst = mount('#et', 'emit-test');
312
+
313
+ let received;
314
+ document.querySelector('#et').addEventListener('my-event', (e) => {
315
+ received = e.detail;
316
+ });
317
+ inst.emit('my-event', { data: 42 });
318
+ expect(received).toEqual({ data: 42 });
319
+ });
320
+ });
321
+
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // destroy
325
+ // ---------------------------------------------------------------------------
326
+
327
+ describe('component — destroy', () => {
328
+ it('clears innerHTML and removes from registry', () => {
329
+ component('destroy-test', {
330
+ render() { return '<div class="will-die">alive</div>'; },
331
+ });
332
+ document.body.innerHTML = '<destroy-test id="dt"></destroy-test>';
333
+ mount('#dt', 'destroy-test');
334
+ expect(document.querySelector('.will-die')).not.toBeNull();
335
+ destroy('#dt');
336
+ expect(document.querySelector('.will-die')).toBeNull();
337
+ expect(getInstance('#dt')).toBeNull();
338
+ });
339
+
340
+ it('double destroy does not throw', () => {
341
+ component('destroy-twice', {
342
+ render() { return '<div>twice</div>'; },
343
+ });
344
+ document.body.innerHTML = '<destroy-twice id="d2"></destroy-twice>';
345
+ mount('#d2', 'destroy-twice');
346
+ destroy('#d2');
347
+ expect(() => destroy('#d2')).not.toThrow();
348
+ });
349
+
350
+ it('stops re-rendering after destroy', async () => {
351
+ let renderCount = 0;
352
+ component('destroy-stop', {
353
+ state: () => ({ n: 0 }),
354
+ render() { renderCount++; return `<div>${this.state.n}</div>`; },
355
+ });
356
+ document.body.innerHTML = '<destroy-stop id="ds"></destroy-stop>';
357
+ const inst = mount('#ds', 'destroy-stop');
358
+ renderCount = 0;
359
+ destroy('#ds');
360
+ inst.state.n = 99;
361
+ await new Promise(r => queueMicrotask(r));
362
+ expect(renderCount).toBe(0);
363
+ });
364
+ });
365
+
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // mountAll
369
+ // ---------------------------------------------------------------------------
370
+
371
+ describe('mountAll()', () => {
372
+ it('auto-mounts all registered component tags', () => {
373
+ component('auto-a', {
374
+ render() { return '<span class="auto-a">A</span>'; },
375
+ });
376
+ component('auto-b', {
377
+ render() { return '<span class="auto-b">B</span>'; },
378
+ });
379
+ document.body.innerHTML = '<auto-a></auto-a><auto-b></auto-b>';
380
+ mountAll();
381
+ expect(document.querySelector('.auto-a').textContent).toBe('A');
382
+ expect(document.querySelector('.auto-b').textContent).toBe('B');
383
+ });
384
+
385
+ it('does not re-mount already mounted components', () => {
386
+ let initCount = 0;
387
+ component('auto-once', {
388
+ init() { initCount++; },
389
+ render() { return '<div>once</div>'; },
390
+ });
391
+ document.body.innerHTML = '<auto-once></auto-once>';
392
+ mountAll();
393
+ mountAll(); // second call
394
+ expect(initCount).toBe(1);
395
+ });
396
+ });
397
+
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // z-if / z-else-if / z-else
401
+ // ---------------------------------------------------------------------------
402
+
403
+ describe('component — z-if directive', () => {
404
+ it('shows element when condition is true', () => {
405
+ component('zif-true', {
406
+ state: () => ({ show: true }),
407
+ render() { return '<p z-if="show">visible</p>'; },
408
+ });
409
+ document.body.innerHTML = '<zif-true id="zt"></zif-true>';
410
+ mount('#zt', 'zif-true');
411
+ expect(document.querySelector('#zt p')).not.toBeNull();
412
+ expect(document.querySelector('#zt p').textContent).toBe('visible');
413
+ });
414
+
415
+ it('removes element when condition is false', () => {
416
+ component('zif-false', {
417
+ state: () => ({ show: false }),
418
+ render() { return '<p z-if="show">hidden</p>'; },
419
+ });
420
+ document.body.innerHTML = '<zif-false id="zf"></zif-false>';
421
+ mount('#zf', 'zif-false');
422
+ expect(document.querySelector('#zf p')).toBeNull();
423
+ });
424
+
425
+ it('supports z-else-if and z-else chain', () => {
426
+ component('zif-chain', {
427
+ state: () => ({ status: 'loading' }),
428
+ render() {
429
+ return `
430
+ <p z-if="status === 'ok'">OK</p>
431
+ <p z-else-if="status === 'loading'">Loading…</p>
432
+ <p z-else>Error</p>
433
+ `;
434
+ },
435
+ });
436
+ document.body.innerHTML = '<zif-chain id="zc"></zif-chain>';
437
+ mount('#zc', 'zif-chain');
438
+ const paras = document.querySelectorAll('#zc p');
439
+ expect(paras.length).toBe(1);
440
+ expect(paras[0].textContent).toBe('Loading…');
441
+ });
442
+ });
443
+
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // z-show
447
+ // ---------------------------------------------------------------------------
448
+
449
+ describe('component — z-show directive', () => {
450
+ it('sets display none when falsy', () => {
451
+ component('zshow-hide', {
452
+ state: () => ({ visible: false }),
453
+ render() { return '<div z-show="visible">content</div>'; },
454
+ });
455
+ document.body.innerHTML = '<zshow-hide id="zsh"></zshow-hide>';
456
+ mount('#zsh', 'zshow-hide');
457
+ expect(document.querySelector('#zsh div').style.display).toBe('none');
458
+ });
459
+
460
+ it('removes display none when truthy', () => {
461
+ component('zshow-show', {
462
+ state: () => ({ visible: true }),
463
+ render() { return '<div z-show="visible">content</div>'; },
464
+ });
465
+ document.body.innerHTML = '<zshow-show id="zss"></zshow-show>';
466
+ mount('#zss', 'zshow-show');
467
+ expect(document.querySelector('#zss div').style.display).toBe('');
468
+ });
469
+ });
470
+
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // z-for
474
+ // ---------------------------------------------------------------------------
475
+
476
+ describe('component — z-for directive', () => {
477
+ it('renders list items', () => {
478
+ component('zfor-list', {
479
+ state: () => ({ items: ['red', 'green', 'blue'] }),
480
+ render() { return '<ul><li z-for="item in items">{{item}}</li></ul>'; },
481
+ });
482
+ document.body.innerHTML = '<zfor-list id="zfl"></zfor-list>';
483
+ mount('#zfl', 'zfor-list');
484
+ const lis = document.querySelectorAll('#zfl li');
485
+ expect(lis.length).toBe(3);
486
+ expect(lis[0].textContent).toBe('red');
487
+ expect(lis[1].textContent).toBe('green');
488
+ expect(lis[2].textContent).toBe('blue');
489
+ });
490
+
491
+ it('supports (item, index) syntax', () => {
492
+ component('zfor-idx', {
493
+ state: () => ({ items: ['a', 'b'] }),
494
+ render() { return '<ul><li z-for="(item, i) in items">{{i}}-{{item}}</li></ul>'; },
495
+ });
496
+ document.body.innerHTML = '<zfor-idx id="zfi"></zfor-idx>';
497
+ mount('#zfi', 'zfor-idx');
498
+ const lis = document.querySelectorAll('#zfi li');
499
+ expect(lis.length).toBe(2);
500
+ expect(lis[0].textContent).toBe('0-a');
501
+ expect(lis[1].textContent).toBe('1-b');
502
+ });
503
+
504
+ it('supports numeric range', () => {
505
+ component('zfor-range', {
506
+ state: () => ({ count: 3 }),
507
+ render() { return '<span z-for="n in count">{{n}}</span>'; },
508
+ });
509
+ document.body.innerHTML = '<zfor-range id="zfr"></zfor-range>';
510
+ mount('#zfr', 'zfor-range');
511
+ const spans = document.querySelectorAll('#zfr span');
512
+ expect(spans.length).toBe(3);
513
+ expect(spans[0].textContent).toBe('1');
514
+ expect(spans[2].textContent).toBe('3');
515
+ });
516
+
517
+ it('renders empty when list is empty', () => {
518
+ component('zfor-empty', {
519
+ state: () => ({ items: [] }),
520
+ render() { return '<ul><li z-for="item in items">{{item}}</li></ul>'; },
521
+ });
522
+ document.body.innerHTML = '<zfor-empty id="zfe"></zfor-empty>';
523
+ mount('#zfe', 'zfor-empty');
524
+ expect(document.querySelectorAll('#zfe li').length).toBe(0);
525
+ });
526
+ });
527
+
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // z-bind / :attr
531
+ // ---------------------------------------------------------------------------
532
+
533
+ describe('component — z-bind directive', () => {
534
+ it('binds attribute dynamically with :attr', () => {
535
+ component('zbind-attr', {
536
+ state: () => ({ cls: 'active' }),
537
+ render() { return '<div :class="cls">content</div>'; },
538
+ });
539
+ document.body.innerHTML = '<zbind-attr id="zba"></zbind-attr>';
540
+ mount('#zba', 'zbind-attr');
541
+ expect(document.querySelector('#zba div').className).toBe('active');
542
+ });
543
+
544
+ it('removes attribute when value is false', () => {
545
+ component('zbind-false', {
546
+ state: () => ({ isDisabled: false }),
547
+ render() { return '<button :disabled="isDisabled">click</button>'; },
548
+ });
549
+ document.body.innerHTML = '<zbind-false id="zbf"></zbind-false>';
550
+ mount('#zbf', 'zbind-false');
551
+ expect(document.querySelector('#zbf button').hasAttribute('disabled')).toBe(false);
552
+ });
553
+
554
+ it('sets boolean attribute when true', () => {
555
+ component('zbind-true', {
556
+ state: () => ({ isDisabled: true }),
557
+ render() { return '<button :disabled="isDisabled">click</button>'; },
558
+ });
559
+ document.body.innerHTML = '<zbind-true id="zbt"></zbind-true>';
560
+ mount('#zbt', 'zbind-true');
561
+ expect(document.querySelector('#zbt button').hasAttribute('disabled')).toBe(true);
562
+ });
563
+ });
564
+
565
+
566
+ // ---------------------------------------------------------------------------
567
+ // z-class
568
+ // ---------------------------------------------------------------------------
569
+
570
+ describe('component — z-class directive', () => {
571
+ it('adds classes from object', () => {
572
+ component('zclass-obj', {
573
+ state: () => ({ isActive: true, isHidden: false }),
574
+ render() { return `<div z-class="{ active: isActive, hidden: isHidden }">test</div>`; },
575
+ });
576
+ document.body.innerHTML = '<zclass-obj id="zco"></zclass-obj>';
577
+ mount('#zco', 'zclass-obj');
578
+ const div = document.querySelector('#zco div');
579
+ expect(div.classList.contains('active')).toBe(true);
580
+ expect(div.classList.contains('hidden')).toBe(false);
581
+ });
582
+ });
583
+
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // z-text
587
+ // ---------------------------------------------------------------------------
588
+
589
+ describe('component — z-text directive', () => {
590
+ it('sets textContent safely', () => {
591
+ component('ztext-test', {
592
+ state: () => ({ msg: 'Hello <b>world</b>' }),
593
+ render() { return '<span z-text="msg"></span>'; },
594
+ });
595
+ document.body.innerHTML = '<ztext-test id="ztt"></ztext-test>';
596
+ mount('#ztt', 'ztext-test');
597
+ const span = document.querySelector('#ztt span');
598
+ expect(span.textContent).toBe('Hello <b>world</b>');
599
+ expect(span.innerHTML).toBe('Hello &lt;b&gt;world&lt;/b&gt;'); // XSS safe
600
+ });
601
+ });
602
+
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // z-html
606
+ // ---------------------------------------------------------------------------
607
+
608
+ describe('component — z-html directive', () => {
609
+ it('sets innerHTML', () => {
610
+ component('zhtml-test', {
611
+ state: () => ({ content: '<strong>bold</strong>' }),
612
+ render() { return '<div z-html="content"></div>'; },
613
+ });
614
+ document.body.innerHTML = '<zhtml-test id="zht"></zhtml-test>';
615
+ mount('#zht', 'zhtml-test');
616
+ expect(document.querySelector('#zht strong').textContent).toBe('bold');
617
+ });
618
+ });
619
+
620
+
621
+ // ---------------------------------------------------------------------------
622
+ // z-ref
623
+ // ---------------------------------------------------------------------------
624
+
625
+ describe('component — z-ref', () => {
626
+ it('populates refs on mount', () => {
627
+ component('zref-test', {
628
+ render() { return '<input z-ref="myInput" type="text">'; },
629
+ });
630
+ document.body.innerHTML = '<zref-test id="zrt"></zref-test>';
631
+ const inst = mount('#zrt', 'zref-test');
632
+ expect(inst.refs.myInput).toBeTruthy();
633
+ expect(inst.refs.myInput.tagName).toBe('INPUT');
634
+ });
635
+ });
636
+
637
+
638
+ // ---------------------------------------------------------------------------
639
+ // z-model (two-way binding)
640
+ // ---------------------------------------------------------------------------
641
+
642
+ describe('component — z-model', () => {
643
+ it('syncs input value from state on mount', () => {
644
+ component('zmodel-init', {
645
+ state: () => ({ name: 'Tony' }),
646
+ render() { return '<input z-model="name">'; },
647
+ });
648
+ document.body.innerHTML = '<zmodel-init id="zmi"></zmodel-init>';
649
+ mount('#zmi', 'zmodel-init');
650
+ expect(document.querySelector('#zmi input').value).toBe('Tony');
651
+ });
652
+
653
+ it('writes input value back to state on input event', async () => {
654
+ component('zmodel-input', {
655
+ state: () => ({ name: '' }),
656
+ render() { return '<input z-model="name">'; },
657
+ });
658
+ document.body.innerHTML = '<zmodel-input id="zmip"></zmodel-input>';
659
+ const inst = mount('#zmip', 'zmodel-input');
660
+ const input = document.querySelector('#zmip input');
661
+ input.value = 'test';
662
+ input.dispatchEvent(new Event('input'));
663
+ expect(inst.state.name).toBe('test');
664
+ });
665
+
666
+ it('syncs checkbox checked state', () => {
667
+ component('zmodel-check', {
668
+ state: () => ({ agree: true }),
669
+ render() { return '<input type="checkbox" z-model="agree">'; },
670
+ });
671
+ document.body.innerHTML = '<zmodel-check id="zmc"></zmodel-check>';
672
+ mount('#zmc', 'zmodel-check');
673
+ expect(document.querySelector('#zmc input').checked).toBe(true);
674
+ });
675
+
676
+ it('writes checkbox change back to state', () => {
677
+ component('zmodel-check2', {
678
+ state: () => ({ agree: false }),
679
+ render() { return '<input type="checkbox" z-model="agree">'; },
680
+ });
681
+ document.body.innerHTML = '<zmodel-check2 id="zmc2"></zmodel-check2>';
682
+ const inst = mount('#zmc2', 'zmodel-check2');
683
+ const input = document.querySelector('#zmc2 input');
684
+ input.checked = true;
685
+ input.dispatchEvent(new Event('change'));
686
+ expect(inst.state.agree).toBe(true);
687
+ });
688
+
689
+ it('syncs textarea value', () => {
690
+ component('zmodel-textarea', {
691
+ state: () => ({ bio: 'Hello' }),
692
+ render() { return '<textarea z-model="bio"></textarea>'; },
693
+ });
694
+ document.body.innerHTML = '<zmodel-textarea id="zmta"></zmodel-textarea>';
695
+ mount('#zmta', 'zmodel-textarea');
696
+ expect(document.querySelector('#zmta textarea').value).toBe('Hello');
697
+ });
698
+ });
699
+
700
+
701
+ // ---------------------------------------------------------------------------
702
+ // z-cloak
703
+ // ---------------------------------------------------------------------------
704
+
705
+ describe('component — z-cloak', () => {
706
+ it('removes z-cloak attribute after render', () => {
707
+ component('zcloak-test', {
708
+ render() { return '<div z-cloak>content</div>'; },
709
+ });
710
+ document.body.innerHTML = '<zcloak-test id="zcl"></zcloak-test>';
711
+ mount('#zcl', 'zcloak-test');
712
+ expect(document.querySelector('#zcl div').hasAttribute('z-cloak')).toBe(false);
713
+ });
714
+ });
715
+
716
+
717
+ // ---------------------------------------------------------------------------
718
+ // z-pre
719
+ // ---------------------------------------------------------------------------
720
+
721
+ describe('component — z-pre', () => {
722
+ it('skips directive processing inside z-pre', () => {
723
+ component('zpre-test', {
724
+ state: () => ({ x: 42 }),
725
+ render() {
726
+ return '<div z-pre><span z-text="x"></span></div>';
727
+ },
728
+ });
729
+ document.body.innerHTML = '<zpre-test id="zpr"></zpre-test>';
730
+ mount('#zpr', 'zpre-test');
731
+ // z-text should NOT be processed inside z-pre
732
+ const span = document.querySelector('#zpr span');
733
+ expect(span.hasAttribute('z-text')).toBe(true);
734
+ expect(span.textContent).toBe('');
735
+ });
736
+ });
737
+
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // z-style
741
+ // ---------------------------------------------------------------------------
742
+
743
+ describe('component — z-style directive', () => {
744
+ it('applies object styles', () => {
745
+ component('zstyle-obj', {
746
+ state: () => ({ color: 'red' }),
747
+ render() { return `<div z-style="{ color: color }">text</div>`; },
748
+ });
749
+ document.body.innerHTML = '<zstyle-obj id="zso"></zstyle-obj>';
750
+ mount('#zso', 'zstyle-obj');
751
+ expect(document.querySelector('#zso div').style.color).toBe('red');
752
+ });
753
+ });
754
+
755
+
756
+ // ---------------------------------------------------------------------------
757
+ // Event binding (@event)
758
+ // ---------------------------------------------------------------------------
759
+
760
+ describe('component — event binding', () => {
761
+ it('handles @click events', () => {
762
+ component('evt-click', {
763
+ state: () => ({ count: 0 }),
764
+ increment() { this.state.count++; },
765
+ render() { return '<button @click="increment">+</button><span class="v">' + this.state.count + '</span>'; },
766
+ });
767
+ document.body.innerHTML = '<evt-click id="ec"></evt-click>';
768
+ const inst = mount('#ec', 'evt-click');
769
+ document.querySelector('#ec button').click();
770
+ expect(inst.state.count).toBe(1);
771
+ });
772
+
773
+ it('passes $event argument', () => {
774
+ let receivedEvent = null;
775
+ component('evt-arg', {
776
+ handleClick(e) { receivedEvent = e; },
777
+ render() { return '<button @click="handleClick($event)">test</button>'; },
778
+ });
779
+ document.body.innerHTML = '<evt-arg id="ea"></evt-arg>';
780
+ mount('#ea', 'evt-arg');
781
+ document.querySelector('#ea button').click();
782
+ expect(receivedEvent).toBeInstanceOf(Event);
783
+ });
784
+
785
+ it('handles z-on:click syntax', () => {
786
+ component('evt-zon', {
787
+ state: () => ({ x: 0 }),
788
+ inc() { this.state.x++; },
789
+ render() { return '<button z-on:click="inc">+</button>'; },
790
+ });
791
+ document.body.innerHTML = '<evt-zon id="ez"></evt-zon>';
792
+ const inst = mount('#ez', 'evt-zon');
793
+ document.querySelector('#ez button').click();
794
+ expect(inst.state.x).toBe(1);
795
+ });
796
+
797
+ it('passes string and number arguments', () => {
798
+ let captured = [];
799
+ component('evt-args', {
800
+ handler(a, b) { captured = [a, b]; },
801
+ render() { return `<button @click="handler('hello', 42)">test</button>`; },
802
+ });
803
+ document.body.innerHTML = '<evt-args id="eag"></evt-args>';
804
+ mount('#eag', 'evt-args');
805
+ document.querySelector('#eag button').click();
806
+ expect(captured).toEqual(['hello', 42]);
807
+ });
808
+ });
809
+
810
+
811
+ // ---------------------------------------------------------------------------
812
+ // Watchers
813
+ // ---------------------------------------------------------------------------
814
+
815
+ describe('component — watchers', () => {
816
+ it('fires watcher when state key changes', async () => {
817
+ const watchFn = vi.fn();
818
+ component('watch-test', {
819
+ state: () => ({ count: 0 }),
820
+ watch: {
821
+ count: watchFn,
822
+ },
823
+ render() { return `<div>${this.state.count}</div>`; },
824
+ });
825
+ document.body.innerHTML = '<watch-test id="wt"></watch-test>';
826
+ const inst = mount('#wt', 'watch-test');
827
+ inst.state.count = 5;
828
+ expect(watchFn).toHaveBeenCalledWith(5, 0);
829
+ });
830
+ });
831
+
832
+
833
+ // ---------------------------------------------------------------------------
834
+ // Slots
835
+ // ---------------------------------------------------------------------------
836
+
837
+ describe('component — slots', () => {
838
+ it('distributes default slot content', () => {
839
+ component('slot-test', {
840
+ render() { return '<div><slot>fallback</slot></div>'; },
841
+ });
842
+ document.body.innerHTML = '<slot-test id="sl"><p>projected</p></slot-test>';
843
+ mount('#sl', 'slot-test');
844
+ expect(document.querySelector('#sl p').textContent).toBe('projected');
845
+ });
846
+
847
+ it('uses fallback when no content provided', () => {
848
+ component('slot-fallback', {
849
+ render() { return '<div><slot>fallback</slot></div>'; },
850
+ });
851
+ document.body.innerHTML = '<slot-fallback id="sf"></slot-fallback>';
852
+ mount('#sf', 'slot-fallback');
853
+ expect(document.querySelector('#sf div').textContent).toBe('fallback');
854
+ });
855
+
856
+ it('distributes named slots', () => {
857
+ component('slot-named', {
858
+ render() {
859
+ return '<header><slot name="header">default header</slot></header><main><slot>main content</slot></main>';
860
+ },
861
+ });
862
+ document.body.innerHTML = '<slot-named id="sn"><div slot="header">My Header</div><p>Body</p></slot-named>';
863
+ mount('#sn', 'slot-named');
864
+ expect(document.querySelector('#sn header').textContent).toBe('My Header');
865
+ expect(document.querySelector('#sn main').textContent).toBe('Body');
866
+ });
867
+ });
868
+
869
+
870
+ // ---------------------------------------------------------------------------
871
+ // Scoped styles
872
+ // ---------------------------------------------------------------------------
873
+
874
+ describe('component — scoped styles', () => {
875
+ it('injects scoped style tag', () => {
876
+ component('style-test', {
877
+ styles: '.box { color: red; }',
878
+ render() { return '<div class="box">styled</div>'; },
879
+ });
880
+ document.body.innerHTML = '<style-test id="st"></style-test>';
881
+ mount('#st', 'style-test');
882
+ const styleEl = document.querySelector('style[data-zq-component="style-test"]');
883
+ expect(styleEl).not.toBeNull();
884
+ expect(styleEl.textContent).toContain('color: red');
885
+ });
886
+ });