zero-query 0.7.5 → 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 (64) hide show
  1. package/README.md +37 -27
  2. package/cli/commands/build.js +110 -1
  3. package/cli/commands/bundle.js +107 -22
  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 +28 -3
  18. package/cli/commands/dev/logger.js +6 -1
  19. package/cli/commands/dev/overlay.js +377 -0
  20. package/cli/commands/dev/server.js +8 -0
  21. package/cli/commands/dev/watcher.js +26 -1
  22. package/cli/help.js +8 -5
  23. package/cli/scaffold/{scripts → app}/app.js +1 -1
  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/app/components/home.js +137 -0
  27. package/cli/scaffold/{scripts → app}/routes.js +1 -1
  28. package/cli/scaffold/{scripts → app}/store.js +6 -6
  29. package/cli/scaffold/assets/.gitkeep +0 -0
  30. package/cli/scaffold/{styles/styles.css → global.css} +3 -2
  31. package/cli/scaffold/index.html +11 -11
  32. package/dist/zquery.dist.zip +0 -0
  33. package/dist/zquery.js +746 -134
  34. package/dist/zquery.min.js +2 -2
  35. package/index.d.ts +11 -9
  36. package/index.js +15 -10
  37. package/package.json +3 -2
  38. package/src/component.js +161 -48
  39. package/src/core.js +57 -11
  40. package/src/diff.js +256 -58
  41. package/src/expression.js +33 -3
  42. package/src/reactive.js +37 -5
  43. package/src/router.js +195 -6
  44. package/tests/component.test.js +582 -0
  45. package/tests/core.test.js +251 -0
  46. package/tests/diff.test.js +333 -2
  47. package/tests/expression.test.js +148 -0
  48. package/tests/http.test.js +108 -0
  49. package/tests/reactive.test.js +148 -0
  50. package/tests/router.test.js +317 -0
  51. package/tests/store.test.js +126 -0
  52. package/tests/utils.test.js +161 -2
  53. package/types/collection.d.ts +17 -2
  54. package/types/component.d.ts +7 -0
  55. package/types/misc.d.ts +13 -0
  56. package/types/router.d.ts +30 -1
  57. package/cli/commands/dev.old.js +0 -520
  58. package/cli/scaffold/scripts/components/home.js +0 -137
  59. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -0
  60. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
  61. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  62. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  63. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  64. /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
@@ -122,6 +122,21 @@ describe('component — lifecycle', () => {
122
122
  document.body.innerHTML = '<life-throw id="lt"></life-throw>';
123
123
  expect(() => mount('#lt', 'life-throw')).not.toThrow();
124
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
+ });
125
140
  });
126
141
 
127
142
 
@@ -144,6 +159,26 @@ describe('component — reactive state', () => {
144
159
  await new Promise(r => queueMicrotask(r));
145
160
  expect(document.querySelector('.count').textContent).toBe('5');
146
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
+ });
147
182
  });
148
183
 
149
184
 
@@ -190,6 +225,22 @@ describe('component — computed', () => {
190
225
  expect(inst.computed.doubled).toBe(10);
191
226
  expect(document.querySelector('.doubled').textContent).toBe('10');
192
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
+ });
193
244
  });
194
245
 
195
246
 
@@ -230,6 +281,20 @@ describe('component — setState', () => {
230
281
  expect(inst.state.a).toBe(10);
231
282
  expect(inst.state.b).toBe(20);
232
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
+ });
233
298
  });
234
299
 
235
300
 
@@ -281,6 +346,21 @@ describe('component — destroy', () => {
281
346
  destroy('#d2');
282
347
  expect(() => destroy('#d2')).not.toThrow();
283
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
+ });
284
364
  });
285
365
 
286
366
 
@@ -301,4 +381,506 @@ describe('mountAll()', () => {
301
381
  expect(document.querySelector('.auto-a').textContent).toBe('A');
302
382
  expect(document.querySelector('.auto-b').textContent).toBe('B');
303
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
+ });
304
886
  });