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.
- package/README.md +37 -27
- package/cli/commands/build.js +110 -1
- package/cli/commands/bundle.js +107 -22
- package/cli/commands/create.js +1 -1
- package/cli/commands/dev/devtools/index.js +56 -0
- package/cli/commands/dev/devtools/js/components.js +49 -0
- package/cli/commands/dev/devtools/js/core.js +409 -0
- package/cli/commands/dev/devtools/js/elements.js +413 -0
- package/cli/commands/dev/devtools/js/network.js +166 -0
- package/cli/commands/dev/devtools/js/performance.js +73 -0
- package/cli/commands/dev/devtools/js/router.js +105 -0
- package/cli/commands/dev/devtools/js/source.js +132 -0
- package/cli/commands/dev/devtools/js/stats.js +35 -0
- package/cli/commands/dev/devtools/js/tabs.js +79 -0
- package/cli/commands/dev/devtools/panel.html +95 -0
- package/cli/commands/dev/devtools/styles.css +244 -0
- package/cli/commands/dev/index.js +28 -3
- package/cli/commands/dev/logger.js +6 -1
- package/cli/commands/dev/overlay.js +377 -0
- package/cli/commands/dev/server.js +8 -0
- package/cli/commands/dev/watcher.js +26 -1
- package/cli/help.js +8 -5
- package/cli/scaffold/{scripts → app}/app.js +1 -1
- package/cli/scaffold/{scripts → app}/components/about.js +4 -4
- package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
- package/cli/scaffold/app/components/home.js +137 -0
- package/cli/scaffold/{scripts → app}/routes.js +1 -1
- package/cli/scaffold/{scripts → app}/store.js +6 -6
- package/cli/scaffold/assets/.gitkeep +0 -0
- package/cli/scaffold/{styles/styles.css → global.css} +3 -2
- package/cli/scaffold/index.html +11 -11
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +746 -134
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +11 -9
- package/index.js +15 -10
- package/package.json +3 -2
- package/src/component.js +161 -48
- package/src/core.js +57 -11
- package/src/diff.js +256 -58
- package/src/expression.js +33 -3
- package/src/reactive.js +37 -5
- package/src/router.js +195 -6
- package/tests/component.test.js +582 -0
- package/tests/core.test.js +251 -0
- package/tests/diff.test.js +333 -2
- package/tests/expression.test.js +148 -0
- package/tests/http.test.js +108 -0
- package/tests/reactive.test.js +148 -0
- package/tests/router.test.js +317 -0
- package/tests/store.test.js +126 -0
- package/tests/utils.test.js +161 -2
- package/types/collection.d.ts +17 -2
- package/types/component.d.ts +7 -0
- package/types/misc.d.ts +13 -0
- package/types/router.d.ts +30 -1
- package/cli/commands/dev.old.js +0 -520
- package/cli/scaffold/scripts/components/home.js +0 -137
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -0
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
package/tests/core.test.js
CHANGED
|
@@ -356,11 +356,58 @@ describe('ZQueryCollection', () => {
|
|
|
356
356
|
expect(col.html()).toBe('<b>bold</b>');
|
|
357
357
|
});
|
|
358
358
|
|
|
359
|
+
it('html() auto-morphs when element has existing children', () => {
|
|
360
|
+
const main = document.querySelector('#main');
|
|
361
|
+
main.innerHTML = '<p id="preserved">old text</p>';
|
|
362
|
+
const ref = main.children[0]; // grab DOM reference
|
|
363
|
+
const col = queryAll('#main');
|
|
364
|
+
col.html('<p id="preserved">new text</p>');
|
|
365
|
+
// Same DOM node preserved — morph, not innerHTML replace
|
|
366
|
+
expect(main.children[0]).toBe(ref);
|
|
367
|
+
expect(main.children[0].textContent).toBe('new text');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('html() uses innerHTML for empty elements (fast first-paint)', () => {
|
|
371
|
+
const main = document.querySelector('#main');
|
|
372
|
+
main.innerHTML = ''; // make it empty
|
|
373
|
+
const col = queryAll('#main');
|
|
374
|
+
col.html('<p id="fresh">hello</p>');
|
|
375
|
+
expect(main.innerHTML).toBe('<p id="fresh">hello</p>');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('empty().html() forces raw innerHTML (opt-out of morph)', () => {
|
|
379
|
+
const main = document.querySelector('#main');
|
|
380
|
+
main.innerHTML = '<p id="old">will be destroyed</p>';
|
|
381
|
+
const ref = main.children[0];
|
|
382
|
+
const col = queryAll('#main');
|
|
383
|
+
col.empty().html('<p id="old">replaced</p>');
|
|
384
|
+
// NOT the same node — empty() cleared children, so html() used innerHTML
|
|
385
|
+
expect(main.children[0]).not.toBe(ref);
|
|
386
|
+
expect(main.children[0].textContent).toBe('replaced');
|
|
387
|
+
});
|
|
388
|
+
|
|
359
389
|
it('text get/set', () => {
|
|
360
390
|
const col = queryAll('.text').eq(0);
|
|
361
391
|
col.text('Changed');
|
|
362
392
|
expect(col.text()).toBe('Changed');
|
|
363
393
|
});
|
|
394
|
+
|
|
395
|
+
it('morph() diffs content instead of replacing', () => {
|
|
396
|
+
const main = document.querySelector('#main');
|
|
397
|
+
main.innerHTML = '<p id="keep">old</p>';
|
|
398
|
+
const ref = main.children[0];
|
|
399
|
+
const col = queryAll('#main');
|
|
400
|
+
col.morph('<p id="keep">new</p>');
|
|
401
|
+
// Same DOM node preserved (morph, not innerHTML)
|
|
402
|
+
expect(main.children[0]).toBe(ref);
|
|
403
|
+
expect(main.children[0].textContent).toBe('new');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('morph() is chainable', () => {
|
|
407
|
+
const col = queryAll('#main');
|
|
408
|
+
const ret = col.morph('<p>m</p>');
|
|
409
|
+
expect(ret).toBe(col);
|
|
410
|
+
});
|
|
364
411
|
});
|
|
365
412
|
|
|
366
413
|
|
|
@@ -700,6 +747,210 @@ describe('CSS dimension methods', () => {
|
|
|
700
747
|
});
|
|
701
748
|
|
|
702
749
|
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
// prop() method
|
|
752
|
+
// ---------------------------------------------------------------------------
|
|
753
|
+
|
|
754
|
+
describe('ZQueryCollection — prop()', () => {
|
|
755
|
+
it('gets a DOM property', () => {
|
|
756
|
+
document.body.innerHTML = '<input type="checkbox" checked>';
|
|
757
|
+
const col = queryAll('input');
|
|
758
|
+
expect(col.prop('checked')).toBe(true);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('sets a DOM property', () => {
|
|
762
|
+
document.body.innerHTML = '<input type="checkbox">';
|
|
763
|
+
const col = queryAll('input');
|
|
764
|
+
col.prop('checked', true);
|
|
765
|
+
expect(col[0].checked).toBe(true);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('sets multiple properties via sequential calls', () => {
|
|
769
|
+
document.body.innerHTML = '<input type="text">';
|
|
770
|
+
const col = queryAll('input');
|
|
771
|
+
col.prop('disabled', true);
|
|
772
|
+
col.prop('value', 'hello');
|
|
773
|
+
expect(col[0].disabled).toBe(true);
|
|
774
|
+
expect(col[0].value).toBe('hello');
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
// css() method
|
|
781
|
+
// ---------------------------------------------------------------------------
|
|
782
|
+
|
|
783
|
+
describe('ZQueryCollection — css()', () => {
|
|
784
|
+
beforeEach(() => {
|
|
785
|
+
document.body.innerHTML = '<div id="styled">test</div>';
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('sets style properties from object', () => {
|
|
789
|
+
queryAll('#styled').css({ color: 'red', 'font-size': '16px' });
|
|
790
|
+
const el = document.getElementById('styled');
|
|
791
|
+
expect(el.style.color).toBe('red');
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it('returns collection for chaining', () => {
|
|
795
|
+
const col = queryAll('#styled').css({ color: 'blue' });
|
|
796
|
+
expect(col).toBeInstanceOf(ZQueryCollection);
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
// val() method
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
describe('ZQueryCollection — val()', () => {
|
|
806
|
+
it('gets input value', () => {
|
|
807
|
+
document.body.innerHTML = '<input value="test">';
|
|
808
|
+
expect(queryAll('input').val()).toBe('test');
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it('sets input value', () => {
|
|
812
|
+
document.body.innerHTML = '<input value="">';
|
|
813
|
+
queryAll('input').val('new value');
|
|
814
|
+
expect(document.querySelector('input').value).toBe('new value');
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('gets select value', () => {
|
|
818
|
+
document.body.innerHTML = '<select><option value="a" selected>A</option><option value="b">B</option></select>';
|
|
819
|
+
expect(queryAll('select').val()).toBe('a');
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('gets textarea value', () => {
|
|
823
|
+
document.body.innerHTML = '<textarea>hello</textarea>';
|
|
824
|
+
expect(queryAll('textarea').val()).toBe('hello');
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
// ---------------------------------------------------------------------------
|
|
830
|
+
// after(), before() methods
|
|
831
|
+
// ---------------------------------------------------------------------------
|
|
832
|
+
|
|
833
|
+
describe('ZQueryCollection — after() / before()', () => {
|
|
834
|
+
beforeEach(() => {
|
|
835
|
+
document.body.innerHTML = '<div id="container"><p id="target">target</p></div>';
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('after() inserts content after element', () => {
|
|
839
|
+
queryAll('#target').after('<span class="after">after</span>');
|
|
840
|
+
const next = document.getElementById('target').nextElementSibling;
|
|
841
|
+
expect(next.className).toBe('after');
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('before() inserts content before element', () => {
|
|
845
|
+
queryAll('#target').before('<span class="before">before</span>');
|
|
846
|
+
const prev = document.getElementById('target').previousElementSibling;
|
|
847
|
+
expect(prev.className).toBe('before');
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
// wrap() method
|
|
854
|
+
// ---------------------------------------------------------------------------
|
|
855
|
+
|
|
856
|
+
describe('ZQueryCollection — wrap()', () => {
|
|
857
|
+
it('wraps element in new parent', () => {
|
|
858
|
+
document.body.innerHTML = '<div id="container"><p id="target">text</p></div>';
|
|
859
|
+
queryAll('#target').wrap('<div class="wrapper"></div>');
|
|
860
|
+
const wrapper = document.querySelector('.wrapper');
|
|
861
|
+
expect(wrapper).not.toBeNull();
|
|
862
|
+
expect(wrapper.querySelector('#target')).not.toBeNull();
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
// ---------------------------------------------------------------------------
|
|
868
|
+
// replaceWith() method
|
|
869
|
+
// ---------------------------------------------------------------------------
|
|
870
|
+
|
|
871
|
+
describe('ZQueryCollection — replaceWith()', () => {
|
|
872
|
+
it('replaces element with new content', () => {
|
|
873
|
+
document.body.innerHTML = '<div id="container"><p id="old">old</p></div>';
|
|
874
|
+
queryAll('#old').replaceWith('<span id="new">new</span>');
|
|
875
|
+
expect(document.querySelector('#old')).toBeNull();
|
|
876
|
+
expect(document.querySelector('#new')).not.toBeNull();
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('auto-morphs when tag name matches (preserves identity)', () => {
|
|
880
|
+
document.body.innerHTML = '<div id="container"><p id="target" class="old">old text</p></div>';
|
|
881
|
+
const target = document.querySelector('#target');
|
|
882
|
+
queryAll('#target').replaceWith('<p id="target" class="new">new text</p>');
|
|
883
|
+
// Same DOM node — morphed, not replaced
|
|
884
|
+
expect(document.querySelector('#target')).toBe(target);
|
|
885
|
+
expect(target.className).toBe('new');
|
|
886
|
+
expect(target.textContent).toBe('new text');
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it('replaces when tag name differs', () => {
|
|
890
|
+
document.body.innerHTML = '<div id="container"><p id="old">old</p></div>';
|
|
891
|
+
const oldRef = document.querySelector('#old');
|
|
892
|
+
queryAll('#old').replaceWith('<section id="replaced">new</section>');
|
|
893
|
+
expect(document.querySelector('#replaced')).not.toBe(oldRef);
|
|
894
|
+
expect(document.querySelector('#replaced').tagName).toBe('SECTION');
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
// ---------------------------------------------------------------------------
|
|
900
|
+
// offset() and position()
|
|
901
|
+
// ---------------------------------------------------------------------------
|
|
902
|
+
|
|
903
|
+
describe('ZQueryCollection — offset() / position()', () => {
|
|
904
|
+
it('offset() returns object with top and left', () => {
|
|
905
|
+
document.body.innerHTML = '<div id="box">box</div>';
|
|
906
|
+
const off = queryAll('#box').offset();
|
|
907
|
+
expect(off).toHaveProperty('top');
|
|
908
|
+
expect(off).toHaveProperty('left');
|
|
909
|
+
expect(typeof off.top).toBe('number');
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it('position() returns object with top and left', () => {
|
|
913
|
+
document.body.innerHTML = '<div id="box">box</div>';
|
|
914
|
+
const pos = queryAll('#box').position();
|
|
915
|
+
expect(pos).toHaveProperty('top');
|
|
916
|
+
expect(pos).toHaveProperty('left');
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
// width() and height()
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
|
|
925
|
+
describe('ZQueryCollection — width() / height()', () => {
|
|
926
|
+
it('width() returns a number', () => {
|
|
927
|
+
document.body.innerHTML = '<div id="box" style="width:100px">box</div>';
|
|
928
|
+
const val = queryAll('#box').width();
|
|
929
|
+
expect(typeof val).toBe('number');
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('height() returns a number', () => {
|
|
933
|
+
document.body.innerHTML = '<div id="box" style="height:50px">box</div>';
|
|
934
|
+
const val = queryAll('#box').height();
|
|
935
|
+
expect(typeof val).toBe('number');
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
// ---------------------------------------------------------------------------
|
|
941
|
+
// animate()
|
|
942
|
+
// ---------------------------------------------------------------------------
|
|
943
|
+
|
|
944
|
+
describe('ZQueryCollection — animate()', () => {
|
|
945
|
+
it('returns a promise', () => {
|
|
946
|
+
document.body.innerHTML = '<div id="box">box</div>';
|
|
947
|
+
const result = queryAll('#box').animate({ opacity: 0 }, 100);
|
|
948
|
+
// animate() returns a Promise
|
|
949
|
+
expect(result).toBeInstanceOf(Promise);
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
|
|
703
954
|
// ---------------------------------------------------------------------------
|
|
704
955
|
// hover() convenience
|
|
705
956
|
// ---------------------------------------------------------------------------
|
package/tests/diff.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { morph } from '../src/diff.js';
|
|
2
|
+
import { morph, morphElement } from '../src/diff.js';
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
@@ -54,6 +54,32 @@ describe('morph — basic', () => {
|
|
|
54
54
|
const root = morphAndGet('<p>hello</p>', '');
|
|
55
55
|
expect(root.children.length).toBe(0);
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
it('handles multiple appends at once', () => {
|
|
59
|
+
const root = morphAndGet('<p>a</p>', '<p>a</p><p>b</p><p>c</p><p>d</p>');
|
|
60
|
+
expect(root.children.length).toBe(4);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('handles complete replacement of all children', () => {
|
|
64
|
+
const root = morphAndGet('<p>a</p><p>b</p>', '<span>x</span><span>y</span>');
|
|
65
|
+
expect(root.children[0].tagName).toBe('SPAN');
|
|
66
|
+
expect(root.children[1].tagName).toBe('SPAN');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('handles single text node to multiple elements', () => {
|
|
70
|
+
const root = document.createElement('div');
|
|
71
|
+
root.textContent = 'hello';
|
|
72
|
+
morph(root, '<p>a</p><p>b</p>');
|
|
73
|
+
expect(root.children.length).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('handles morphing identical content (no-op)', () => {
|
|
77
|
+
const root = el('<p>same</p>');
|
|
78
|
+
const child = root.children[0];
|
|
79
|
+
morph(root, '<p>same</p>');
|
|
80
|
+
expect(root.children[0]).toBe(child); // identity preserved
|
|
81
|
+
expect(root.innerHTML).toBe('<p>same</p>');
|
|
82
|
+
});
|
|
57
83
|
});
|
|
58
84
|
|
|
59
85
|
|
|
@@ -76,6 +102,30 @@ describe('morph — attributes', () => {
|
|
|
76
102
|
const root = morphAndGet('<div class="x" id="y"></div>', '<div class="x"></div>');
|
|
77
103
|
expect(root.children[0].hasAttribute('id')).toBe(false);
|
|
78
104
|
});
|
|
105
|
+
|
|
106
|
+
it('handles multiple attribute changes simultaneously', () => {
|
|
107
|
+
const root = morphAndGet(
|
|
108
|
+
'<div class="a" id="old" data-x="1"></div>',
|
|
109
|
+
'<div class="b" title="new" data-y="2"></div>'
|
|
110
|
+
);
|
|
111
|
+
const d = root.children[0];
|
|
112
|
+
expect(d.className).toBe('b');
|
|
113
|
+
expect(d.hasAttribute('id')).toBe(false);
|
|
114
|
+
expect(d.hasAttribute('data-x')).toBe(false);
|
|
115
|
+
expect(d.getAttribute('title')).toBe('new');
|
|
116
|
+
expect(d.getAttribute('data-y')).toBe('2');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('preserves attributes that have not changed', () => {
|
|
120
|
+
const root = morphAndGet('<div class="keep" id="a"></div>', '<div class="keep" id="b"></div>');
|
|
121
|
+
expect(root.children[0].className).toBe('keep');
|
|
122
|
+
expect(root.children[0].id).toBe('b');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('handles attribute with empty value', () => {
|
|
126
|
+
const root = morphAndGet('<div></div>', '<div hidden></div>');
|
|
127
|
+
expect(root.children[0].hasAttribute('hidden')).toBe(true);
|
|
128
|
+
});
|
|
79
129
|
});
|
|
80
130
|
|
|
81
131
|
|
|
@@ -87,7 +137,6 @@ describe('morph — keyed', () => {
|
|
|
87
137
|
it('matches elements by z-key', () => {
|
|
88
138
|
const root = el('<div z-key="a">A</div><div z-key="b">B</div>');
|
|
89
139
|
morph(root, '<div z-key="b">B-updated</div><div z-key="a">A-updated</div>');
|
|
90
|
-
// Keys should match — b first, then a
|
|
91
140
|
const kids = [...root.children];
|
|
92
141
|
expect(kids[0].getAttribute('z-key')).toBe('b');
|
|
93
142
|
expect(kids[0].textContent).toBe('B-updated');
|
|
@@ -109,6 +158,47 @@ describe('morph — keyed', () => {
|
|
|
109
158
|
expect(root.children.length).toBe(2);
|
|
110
159
|
expect(root.children[1].getAttribute('z-key')).toBe('b');
|
|
111
160
|
});
|
|
161
|
+
|
|
162
|
+
it('preserves keyed node identity on reorder', () => {
|
|
163
|
+
const root = el('<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>');
|
|
164
|
+
const nodeA = root.children[0];
|
|
165
|
+
const nodeB = root.children[1];
|
|
166
|
+
const nodeC = root.children[2];
|
|
167
|
+
morph(root, '<div z-key="c">C</div><div z-key="a">A</div><div z-key="b">B</div>');
|
|
168
|
+
// Same DOM nodes, just reordered
|
|
169
|
+
expect(root.children[0]).toBe(nodeC);
|
|
170
|
+
expect(root.children[1]).toBe(nodeA);
|
|
171
|
+
expect(root.children[2]).toBe(nodeB);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('handles mixed keyed and unkeyed nodes', () => {
|
|
175
|
+
const root = el('<div z-key="a">A</div><p>unkeyed</p><div z-key="b">B</div>');
|
|
176
|
+
morph(root, '<div z-key="b">B2</div><span>new-unkeyed</span><div z-key="a">A2</div>');
|
|
177
|
+
expect(root.children[0].getAttribute('z-key')).toBe('b');
|
|
178
|
+
expect(root.children[0].textContent).toBe('B2');
|
|
179
|
+
expect(root.children[2].getAttribute('z-key')).toBe('a');
|
|
180
|
+
expect(root.children[2].textContent).toBe('A2');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('handles complete key set swap', () => {
|
|
184
|
+
const root = el('<div z-key="a">A</div><div z-key="b">B</div>');
|
|
185
|
+
morph(root, '<div z-key="c">C</div><div z-key="d">D</div>');
|
|
186
|
+
expect(root.children.length).toBe(2);
|
|
187
|
+
expect(root.children[0].getAttribute('z-key')).toBe('c');
|
|
188
|
+
expect(root.children[1].getAttribute('z-key')).toBe('d');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('handles reverse order (worst case for naive diff)', () => {
|
|
192
|
+
const root = el(
|
|
193
|
+
'<div z-key="1">1</div><div z-key="2">2</div><div z-key="3">3</div>' +
|
|
194
|
+
'<div z-key="4">4</div><div z-key="5">5</div>'
|
|
195
|
+
);
|
|
196
|
+
morph(root,
|
|
197
|
+
'<div z-key="5">5</div><div z-key="4">4</div><div z-key="3">3</div>' +
|
|
198
|
+
'<div z-key="2">2</div><div z-key="1">1</div>'
|
|
199
|
+
);
|
|
200
|
+
expect([...root.children].map(c => c.getAttribute('z-key'))).toEqual(['5','4','3','2','1']);
|
|
201
|
+
});
|
|
112
202
|
});
|
|
113
203
|
|
|
114
204
|
|
|
@@ -135,6 +225,27 @@ describe('morph — form elements', () => {
|
|
|
135
225
|
morph(root, '<textarea>new</textarea>');
|
|
136
226
|
expect(root.querySelector('textarea').value).toBe('new');
|
|
137
227
|
});
|
|
228
|
+
|
|
229
|
+
it('syncs input disabled state', () => {
|
|
230
|
+
const root = el('<input>');
|
|
231
|
+
morph(root, '<input disabled>');
|
|
232
|
+
expect(root.querySelector('input').disabled).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('syncs radio button', () => {
|
|
236
|
+
const root = el('<input type="radio" value="a">');
|
|
237
|
+
root.querySelector('input').checked = false;
|
|
238
|
+
morph(root, '<input type="radio" value="a" checked>');
|
|
239
|
+
expect(root.querySelector('input').checked).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('syncs select element value', () => {
|
|
243
|
+
const root = el('<select><option value="a">A</option><option value="b">B</option></select>');
|
|
244
|
+
root.querySelector('select').value = 'a';
|
|
245
|
+
morph(root, '<select><option value="a">A</option><option value="b" selected>B</option></select>');
|
|
246
|
+
// Note: jsdom may handle selected differently, but the morph should attempt to sync
|
|
247
|
+
expect(root.querySelector('select')).toBeTruthy();
|
|
248
|
+
});
|
|
138
249
|
});
|
|
139
250
|
|
|
140
251
|
|
|
@@ -149,6 +260,22 @@ describe('morph — text nodes', () => {
|
|
|
149
260
|
morph(root, 'new');
|
|
150
261
|
expect(root.textContent).toBe('new');
|
|
151
262
|
});
|
|
263
|
+
|
|
264
|
+
it('handles multiple text nodes', () => {
|
|
265
|
+
const root = document.createElement('div');
|
|
266
|
+
root.appendChild(document.createTextNode('a'));
|
|
267
|
+
root.appendChild(document.createTextNode('b'));
|
|
268
|
+
morph(root, 'xy');
|
|
269
|
+
expect(root.textContent).toBe('xy');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('handles comment nodes', () => {
|
|
273
|
+
const root = document.createElement('div');
|
|
274
|
+
root.appendChild(document.createComment('old comment'));
|
|
275
|
+
morph(root, '<!-- new comment -->');
|
|
276
|
+
expect(root.childNodes[0].nodeType).toBe(8); // Comment node
|
|
277
|
+
expect(root.childNodes[0].nodeValue).toBe(' new comment ');
|
|
278
|
+
});
|
|
152
279
|
});
|
|
153
280
|
|
|
154
281
|
|
|
@@ -176,6 +303,25 @@ describe('morph — nested', () => {
|
|
|
176
303
|
expect(root.querySelector('p').textContent).toBe('new text');
|
|
177
304
|
expect(root.querySelector('span')).toBeNull();
|
|
178
305
|
});
|
|
306
|
+
|
|
307
|
+
it('handles deeply nested changes (3 levels)', () => {
|
|
308
|
+
const root = morphAndGet(
|
|
309
|
+
'<div><div><div><span>deep</span></div></div></div>',
|
|
310
|
+
'<div><div><div><span>updated</span></div></div></div>'
|
|
311
|
+
);
|
|
312
|
+
expect(root.querySelector('span').textContent).toBe('updated');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('handles nested list reorder with keys', () => {
|
|
316
|
+
const root = morphAndGet(
|
|
317
|
+
'<ul><li z-key="a">A</li><li z-key="b">B</li><li z-key="c">C</li></ul>',
|
|
318
|
+
'<ul><li z-key="c">C</li><li z-key="a">A</li><li z-key="b">B</li></ul>'
|
|
319
|
+
);
|
|
320
|
+
const items = root.querySelectorAll('li');
|
|
321
|
+
expect(items[0].getAttribute('z-key')).toBe('c');
|
|
322
|
+
expect(items[1].getAttribute('z-key')).toBe('a');
|
|
323
|
+
expect(items[2].getAttribute('z-key')).toBe('b');
|
|
324
|
+
});
|
|
179
325
|
});
|
|
180
326
|
|
|
181
327
|
|
|
@@ -191,4 +337,189 @@ describe('morph — preservation', () => {
|
|
|
191
337
|
expect(root.children[0]).toBe(firstP); // same DOM node
|
|
192
338
|
expect(root.children[1].textContent).toBe('changed');
|
|
193
339
|
});
|
|
340
|
+
|
|
341
|
+
it('preserves identity of deeply nested unchanged nodes', () => {
|
|
342
|
+
const root = el('<div><ul><li>item</li></ul></div>');
|
|
343
|
+
const li = root.querySelector('li');
|
|
344
|
+
morph(root, '<div><ul><li>item</li></ul></div>');
|
|
345
|
+
expect(root.querySelector('li')).toBe(li);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// z-skip directive
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
describe('morph — z-skip', () => {
|
|
355
|
+
it('skips subtrees with z-skip attribute', () => {
|
|
356
|
+
const root = el('<div z-skip><p>original</p></div>');
|
|
357
|
+
const p = root.querySelector('p');
|
|
358
|
+
morph(root, '<div z-skip><p>updated</p></div>');
|
|
359
|
+
// The inner content should NOT change because z-skip is set
|
|
360
|
+
expect(root.querySelector('p')).toBe(p);
|
|
361
|
+
expect(root.querySelector('p').textContent).toBe('original');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('morphs sibling of z-skip normally', () => {
|
|
365
|
+
const root = el('<div z-skip><p>skip me</p></div><div><p>morph me</p></div>');
|
|
366
|
+
morph(root, '<div z-skip><p>different</p></div><div><p>morphed</p></div>');
|
|
367
|
+
expect(root.children[0].querySelector('p').textContent).toBe('skip me');
|
|
368
|
+
expect(root.children[1].querySelector('p').textContent).toBe('morphed');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// isEqualNode fast bail-out
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
describe('morph — fast bail-out', () => {
|
|
378
|
+
it('uses isEqualNode to skip identical elements', () => {
|
|
379
|
+
const root = el('<div><p class="a" id="b">text</p></div>');
|
|
380
|
+
const p = root.querySelector('p');
|
|
381
|
+
morph(root, '<div><p class="a" id="b">text</p></div>');
|
|
382
|
+
// isEqualNode returns true, so the node should be kept
|
|
383
|
+
expect(root.querySelector('p')).toBe(p);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Edge cases & stress
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
describe('morph — edge cases', () => {
|
|
393
|
+
it('handles whitespace-only text nodes', () => {
|
|
394
|
+
const root = morphAndGet('<div> </div>', '<div>text</div>');
|
|
395
|
+
expect(root.children[0].textContent).toBe('text');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('handles node type change (text → element)', () => {
|
|
399
|
+
const root = document.createElement('div');
|
|
400
|
+
root.appendChild(document.createTextNode('text'));
|
|
401
|
+
morph(root, '<p>element</p>');
|
|
402
|
+
expect(root.children[0].tagName).toBe('P');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('handles large number of children', () => {
|
|
406
|
+
const oldItems = Array.from({ length: 100 }, (_, i) => `<li>${i}</li>`).join('');
|
|
407
|
+
const newItems = Array.from({ length: 100 }, (_, i) => `<li>${i * 2}</li>`).join('');
|
|
408
|
+
const root = morphAndGet(`<ul>${oldItems}</ul>`, `<ul>${newItems}</ul>`);
|
|
409
|
+
expect(root.querySelectorAll('li').length).toBe(100);
|
|
410
|
+
expect(root.querySelectorAll('li')[50].textContent).toBe('100');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('handles large keyed list shuffle', () => {
|
|
414
|
+
const ids = Array.from({ length: 50 }, (_, i) => i);
|
|
415
|
+
const shuffled = [...ids].sort(() => Math.random() - 0.5);
|
|
416
|
+
const oldHTML = ids.map(i => `<div z-key="${i}">${i}</div>`).join('');
|
|
417
|
+
const newHTML = shuffled.map(i => `<div z-key="${i}">${i}</div>`).join('');
|
|
418
|
+
const root = el(oldHTML);
|
|
419
|
+
morph(root, newHTML);
|
|
420
|
+
const result = [...root.children].map(c => c.getAttribute('z-key'));
|
|
421
|
+
expect(result).toEqual(shuffled.map(String));
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('handles empty string to empty string', () => {
|
|
425
|
+
const root = morphAndGet('', '');
|
|
426
|
+
expect(root.childNodes.length).toBe(0);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// Auto-keyed reconciliation (id / data-id / data-key)
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
describe('morph — auto-keyed via id attribute', () => {
|
|
436
|
+
it('reorders elements by id without z-key', () => {
|
|
437
|
+
const root = el(
|
|
438
|
+
'<div id="a">A</div><div id="b">B</div><div id="c">C</div>'
|
|
439
|
+
);
|
|
440
|
+
const refA = root.children[0];
|
|
441
|
+
const refB = root.children[1];
|
|
442
|
+
morph(root, '<div id="c">C</div><div id="a">A</div><div id="b">B</div>');
|
|
443
|
+
// Node identity preserved — same DOM nodes, different order
|
|
444
|
+
expect(root.children[1]).toBe(refA);
|
|
445
|
+
expect(root.children[2]).toBe(refB);
|
|
446
|
+
expect([...root.children].map(c => c.id)).toEqual(['c', 'a', 'b']);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('reorders elements by data-id without z-key', () => {
|
|
450
|
+
const root = el(
|
|
451
|
+
'<li data-id="1">One</li><li data-id="2">Two</li><li data-id="3">Three</li>'
|
|
452
|
+
);
|
|
453
|
+
const ref1 = root.children[0];
|
|
454
|
+
morph(root, '<li data-id="3">Three</li><li data-id="1">One</li><li data-id="2">Two</li>');
|
|
455
|
+
expect(root.children[1]).toBe(ref1);
|
|
456
|
+
expect([...root.children].map(c => c.dataset.id)).toEqual(['3', '1', '2']);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('reorders elements by data-key without z-key', () => {
|
|
460
|
+
const root = el(
|
|
461
|
+
'<span data-key="x">X</span><span data-key="y">Y</span>'
|
|
462
|
+
);
|
|
463
|
+
const refX = root.children[0];
|
|
464
|
+
morph(root, '<span data-key="y">Y</span><span data-key="x">X</span>');
|
|
465
|
+
expect(root.children[1]).toBe(refX);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('removes auto-keyed elements not in new tree', () => {
|
|
469
|
+
const root = morphAndGet(
|
|
470
|
+
'<p id="a">A</p><p id="b">B</p><p id="c">C</p>',
|
|
471
|
+
'<p id="a">A</p><p id="c">C</p>'
|
|
472
|
+
);
|
|
473
|
+
expect(root.children.length).toBe(2);
|
|
474
|
+
expect([...root.children].map(c => c.id)).toEqual(['a', 'c']);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('inserts new auto-keyed elements', () => {
|
|
478
|
+
const root = morphAndGet(
|
|
479
|
+
'<p id="a">A</p><p id="c">C</p>',
|
|
480
|
+
'<p id="a">A</p><p id="b">B</p><p id="c">C</p>'
|
|
481
|
+
);
|
|
482
|
+
expect(root.children.length).toBe(3);
|
|
483
|
+
expect([...root.children].map(c => c.id)).toEqual(['a', 'b', 'c']);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// morphElement — single-element morph
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
describe('morphElement', () => {
|
|
493
|
+
it('morphs attributes and children, preserving identity', () => {
|
|
494
|
+
const root = el('<div class="old"><span>old</span></div>');
|
|
495
|
+
const target = root.children[0];
|
|
496
|
+
const result = morphElement(target, '<div class="new"><span>new</span></div>');
|
|
497
|
+
expect(result).toBe(target); // same node
|
|
498
|
+
expect(target.className).toBe('new');
|
|
499
|
+
expect(target.children[0].textContent).toBe('new');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('replaces element when tag name differs', () => {
|
|
503
|
+
const root = el('<div>old</div>');
|
|
504
|
+
const target = root.children[0];
|
|
505
|
+
const result = morphElement(target, '<section>new</section>');
|
|
506
|
+
expect(result).not.toBe(target);
|
|
507
|
+
expect(result.tagName).toBe('SECTION');
|
|
508
|
+
expect(result.textContent).toBe('new');
|
|
509
|
+
expect(root.children[0]).toBe(result);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('returns original element when nothing changes', () => {
|
|
513
|
+
const root = el('<p class="x">same</p>');
|
|
514
|
+
const target = root.children[0];
|
|
515
|
+
const result = morphElement(target, '<p class="x">same</p>');
|
|
516
|
+
expect(result).toBe(target);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('handles empty new HTML gracefully', () => {
|
|
520
|
+
const root = el('<div>content</div>');
|
|
521
|
+
const target = root.children[0];
|
|
522
|
+
const result = morphElement(target, '');
|
|
523
|
+
expect(result).toBe(target); // no-op on empty
|
|
524
|
+
});
|
|
194
525
|
});
|