zero-query 0.9.1 → 0.9.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 +11 -8
- package/cli/commands/dev/devtools/js/elements.js +5 -0
- package/cli/scaffold/app/app.js +1 -1
- package/cli/scaffold/global.css +1 -2
- package/cli/scaffold/index.html +2 -1
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +429 -35
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +46 -2
- package/index.js +32 -4
- package/package.json +1 -1
- package/src/component.js +98 -8
- package/src/core.js +3 -1
- package/src/expression.js +103 -16
- package/src/http.js +6 -2
- package/src/router.js +6 -1
- package/src/utils.js +191 -5
- package/tests/audit.test.js +4030 -0
- package/tests/component.test.js +1185 -0
- package/tests/core.test.js +41 -0
- package/tests/expression.test.js +10 -10
- package/tests/utils.test.js +543 -1
- package/types/utils.d.ts +103 -0
package/tests/component.test.js
CHANGED
|
@@ -864,6 +864,437 @@ describe('component — slots', () => {
|
|
|
864
864
|
expect(document.querySelector('#sn header').textContent).toBe('My Header');
|
|
865
865
|
expect(document.querySelector('#sn main').textContent).toBe('Body');
|
|
866
866
|
});
|
|
867
|
+
|
|
868
|
+
// --- Edge cases: self-closing and whitespace ---
|
|
869
|
+
|
|
870
|
+
it('handles self-closing <slot /> syntax', () => {
|
|
871
|
+
component('slot-selfclose', {
|
|
872
|
+
render() { return '<div><slot /></div>'; },
|
|
873
|
+
});
|
|
874
|
+
document.body.innerHTML = '<slot-selfclose id="ssc"><p>content</p></slot-selfclose>';
|
|
875
|
+
mount('#ssc', 'slot-selfclose');
|
|
876
|
+
expect(document.querySelector('#ssc p').textContent).toBe('content');
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('handles self-closing named <slot name="x" />', () => {
|
|
880
|
+
component('slot-selfclose-named', {
|
|
881
|
+
render() { return '<div><slot name="top" /></div>'; },
|
|
882
|
+
});
|
|
883
|
+
document.body.innerHTML = '<slot-selfclose-named id="sscn"><span slot="top">Top!</span></slot-selfclose-named>';
|
|
884
|
+
mount('#sscn', 'slot-selfclose-named');
|
|
885
|
+
expect(document.querySelector('#sscn span').textContent).toBe('Top!');
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('uses fallback for self-closing slot when no content', () => {
|
|
889
|
+
component('slot-selfclose-fb', {
|
|
890
|
+
render() { return '<div><slot />empty</div>'; },
|
|
891
|
+
});
|
|
892
|
+
document.body.innerHTML = '<slot-selfclose-fb id="sscfb"></slot-selfclose-fb>';
|
|
893
|
+
mount('#sscfb', 'slot-selfclose-fb');
|
|
894
|
+
// self-closing slot has no fallback content, so it's replaced with ''
|
|
895
|
+
expect(document.querySelector('#sscfb div').textContent).toBe('empty');
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// --- Text-only slot content ---
|
|
899
|
+
|
|
900
|
+
it('projects plain text (no wrapper element) into default slot', () => {
|
|
901
|
+
component('slot-textonly', {
|
|
902
|
+
render() { return '<div><slot>fallback</slot></div>'; },
|
|
903
|
+
});
|
|
904
|
+
document.body.innerHTML = '<slot-textonly id="sto">Hello World</slot-textonly>';
|
|
905
|
+
mount('#sto', 'slot-textonly');
|
|
906
|
+
expect(document.querySelector('#sto div').textContent).toBe('Hello World');
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it('ignores whitespace-only text nodes', () => {
|
|
910
|
+
component('slot-whitespace', {
|
|
911
|
+
render() { return '<div><slot>fallback</slot></div>'; },
|
|
912
|
+
});
|
|
913
|
+
document.body.innerHTML = '<slot-whitespace id="sw"> \n\t </slot-whitespace>';
|
|
914
|
+
mount('#sw', 'slot-whitespace');
|
|
915
|
+
expect(document.querySelector('#sw div').textContent).toBe('fallback');
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// --- Multiple elements in default slot ---
|
|
919
|
+
|
|
920
|
+
it('projects multiple elements into default slot', () => {
|
|
921
|
+
component('slot-multi', {
|
|
922
|
+
render() { return '<div><slot></slot></div>'; },
|
|
923
|
+
});
|
|
924
|
+
document.body.innerHTML = '<slot-multi id="sm"><p>One</p><p>Two</p><p>Three</p></slot-multi>';
|
|
925
|
+
mount('#sm', 'slot-multi');
|
|
926
|
+
const paras = document.querySelectorAll('#sm div p');
|
|
927
|
+
expect(paras.length).toBe(3);
|
|
928
|
+
expect(paras[0].textContent).toBe('One');
|
|
929
|
+
expect(paras[2].textContent).toBe('Three');
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
// --- Mixed named + default slot content ---
|
|
933
|
+
|
|
934
|
+
it('separates named and default content correctly', () => {
|
|
935
|
+
component('slot-mixed', {
|
|
936
|
+
render() {
|
|
937
|
+
return '<header><slot name="title">default title</slot></header><section><slot>default body</slot></section>';
|
|
938
|
+
},
|
|
939
|
+
});
|
|
940
|
+
document.body.innerHTML = `
|
|
941
|
+
<slot-mixed id="smx">
|
|
942
|
+
<h1 slot="title">Custom Title</h1>
|
|
943
|
+
<p>Paragraph 1</p>
|
|
944
|
+
<p>Paragraph 2</p>
|
|
945
|
+
</slot-mixed>`;
|
|
946
|
+
mount('#smx', 'slot-mixed');
|
|
947
|
+
expect(document.querySelector('#smx header').textContent).toBe('Custom Title');
|
|
948
|
+
const section = document.querySelector('#smx section');
|
|
949
|
+
expect(section.querySelectorAll('p').length).toBe(2);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// --- Multiple named slots ---
|
|
953
|
+
|
|
954
|
+
it('distributes multiple named slots', () => {
|
|
955
|
+
component('slot-multinamed', {
|
|
956
|
+
render() {
|
|
957
|
+
return '<header><slot name="header">H</slot></header><footer><slot name="footer">F</slot></footer><main><slot>M</slot></main>';
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
document.body.innerHTML = `
|
|
961
|
+
<slot-multinamed id="smn">
|
|
962
|
+
<div slot="header">My Header</div>
|
|
963
|
+
<div slot="footer">My Footer</div>
|
|
964
|
+
<p>Body content</p>
|
|
965
|
+
</slot-multinamed>`;
|
|
966
|
+
mount('#smn', 'slot-multinamed');
|
|
967
|
+
expect(document.querySelector('#smn header').textContent).toBe('My Header');
|
|
968
|
+
expect(document.querySelector('#smn footer').textContent).toBe('My Footer');
|
|
969
|
+
expect(document.querySelector('#smn main p').textContent).toBe('Body content');
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
// --- Named slot with multiple elements ---
|
|
973
|
+
|
|
974
|
+
it('accumulates multiple elements for the same named slot', () => {
|
|
975
|
+
component('slot-accumulate', {
|
|
976
|
+
render() { return '<div><slot name="items">none</slot></div>'; },
|
|
977
|
+
});
|
|
978
|
+
document.body.innerHTML = `
|
|
979
|
+
<slot-accumulate id="sac">
|
|
980
|
+
<li slot="items">A</li>
|
|
981
|
+
<li slot="items">B</li>
|
|
982
|
+
<li slot="items">C</li>
|
|
983
|
+
</slot-accumulate>`;
|
|
984
|
+
mount('#sac', 'slot-accumulate');
|
|
985
|
+
const lis = document.querySelectorAll('#sac li');
|
|
986
|
+
expect(lis.length).toBe(3);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
// --- Fallback for named slots ---
|
|
990
|
+
|
|
991
|
+
it('uses fallback for unmatched named slot', () => {
|
|
992
|
+
component('slot-namedfb', {
|
|
993
|
+
render() {
|
|
994
|
+
return '<header><slot name="header">Default Header</slot></header><footer><slot name="footer">Default Footer</slot></footer>';
|
|
995
|
+
},
|
|
996
|
+
});
|
|
997
|
+
// Only provide header, not footer
|
|
998
|
+
document.body.innerHTML = '<slot-namedfb id="snfb"><span slot="header">Custom H</span></slot-namedfb>';
|
|
999
|
+
mount('#snfb', 'slot-namedfb');
|
|
1000
|
+
expect(document.querySelector('#snfb header').textContent).toBe('Custom H');
|
|
1001
|
+
expect(document.querySelector('#snfb footer').textContent).toBe('Default Footer');
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// --- Empty element in default slot ---
|
|
1005
|
+
|
|
1006
|
+
it('projects empty elements into default slot', () => {
|
|
1007
|
+
component('slot-emptyel', {
|
|
1008
|
+
render() { return '<div><slot>fallback</slot></div>'; },
|
|
1009
|
+
});
|
|
1010
|
+
document.body.innerHTML = '<slot-emptyel id="see"><br></slot-emptyel>';
|
|
1011
|
+
mount('#see', 'slot-emptyel');
|
|
1012
|
+
expect(document.querySelector('#see div br')).not.toBeNull();
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
// --- Slot content with HTML attributes and classes ---
|
|
1016
|
+
|
|
1017
|
+
it('preserves attributes and classes on slotted content', () => {
|
|
1018
|
+
component('slot-attrs', {
|
|
1019
|
+
render() { return '<div><slot></slot></div>'; },
|
|
1020
|
+
});
|
|
1021
|
+
document.body.innerHTML = '<slot-attrs id="sa"><p class="highlight" data-id="42">Styled</p></slot-attrs>';
|
|
1022
|
+
mount('#sa', 'slot-attrs');
|
|
1023
|
+
const p = document.querySelector('#sa p');
|
|
1024
|
+
expect(p.classList.contains('highlight')).toBe(true);
|
|
1025
|
+
expect(p.getAttribute('data-id')).toBe('42');
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
// --- Slot content with nested HTML ---
|
|
1029
|
+
|
|
1030
|
+
it('projects deeply nested HTML into slot', () => {
|
|
1031
|
+
component('slot-nested', {
|
|
1032
|
+
render() { return '<div><slot></slot></div>'; },
|
|
1033
|
+
});
|
|
1034
|
+
document.body.innerHTML = '<slot-nested id="sne"><div><ul><li><strong>Deep</strong></li></ul></div></slot-nested>';
|
|
1035
|
+
mount('#sne', 'slot-nested');
|
|
1036
|
+
expect(document.querySelector('#sne strong').textContent).toBe('Deep');
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// --- Slot content persists across re-renders ---
|
|
1040
|
+
|
|
1041
|
+
it('preserves slot content after state change re-render', async () => {
|
|
1042
|
+
component('slot-rerender', {
|
|
1043
|
+
state: () => ({ count: 0 }),
|
|
1044
|
+
render() {
|
|
1045
|
+
return `<p>Count: ${this.state.count}</p><div class="slotted"><slot>fallback</slot></div>`;
|
|
1046
|
+
},
|
|
1047
|
+
});
|
|
1048
|
+
document.body.innerHTML = '<slot-rerender id="srr"><span>Projected!</span></slot-rerender>';
|
|
1049
|
+
const inst = mount('#srr', 'slot-rerender');
|
|
1050
|
+
expect(document.querySelector('#srr .slotted span').textContent).toBe('Projected!');
|
|
1051
|
+
|
|
1052
|
+
// Trigger re-render via state change (batched — need microtask flush)
|
|
1053
|
+
inst.state.count = 5;
|
|
1054
|
+
await new Promise(r => queueMicrotask(r));
|
|
1055
|
+
expect(document.querySelector('#srr p').textContent).toBe('Count: 5');
|
|
1056
|
+
expect(document.querySelector('#srr .slotted span').textContent).toBe('Projected!');
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// --- Slot with special characters ---
|
|
1060
|
+
|
|
1061
|
+
it('handles slot content with special characters', () => {
|
|
1062
|
+
component('slot-special', {
|
|
1063
|
+
render() { return '<div><slot></slot></div>'; },
|
|
1064
|
+
});
|
|
1065
|
+
document.body.innerHTML = '<slot-special id="ssp"><p>& <tag> "quotes"</p></slot-special>';
|
|
1066
|
+
mount('#ssp', 'slot-special');
|
|
1067
|
+
const p = document.querySelector('#ssp p');
|
|
1068
|
+
expect(p.textContent).toContain('& <tag> "quotes"');
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
// --- No slot tag in template — content is replaced entirely ---
|
|
1072
|
+
|
|
1073
|
+
it('discards projected content when template has no slot', () => {
|
|
1074
|
+
component('slot-nosite', {
|
|
1075
|
+
render() { return '<div>No slot here</div>'; },
|
|
1076
|
+
});
|
|
1077
|
+
document.body.innerHTML = '<slot-nosite id="sns"><p>Orphan</p></slot-nosite>';
|
|
1078
|
+
mount('#sns', 'slot-nosite');
|
|
1079
|
+
expect(document.querySelector('#sns div').textContent).toBe('No slot here');
|
|
1080
|
+
expect(document.querySelector('#sns p')).toBeNull();
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
// --- Multiple default slots in template (both should be filled) ---
|
|
1084
|
+
|
|
1085
|
+
it('fills multiple default slot sites with the same content', () => {
|
|
1086
|
+
component('slot-dupdefault', {
|
|
1087
|
+
render() {
|
|
1088
|
+
return '<div class="a"><slot>fb1</slot></div><div class="b"><slot>fb2</slot></div>';
|
|
1089
|
+
},
|
|
1090
|
+
});
|
|
1091
|
+
document.body.innerHTML = '<slot-dupdefault id="sdd"><p>Content</p></slot-dupdefault>';
|
|
1092
|
+
mount('#sdd', 'slot-dupdefault');
|
|
1093
|
+
expect(document.querySelector('#sdd .a p').textContent).toBe('Content');
|
|
1094
|
+
expect(document.querySelector('#sdd .b p').textContent).toBe('Content');
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
// --- Named slot content with inline styles ---
|
|
1098
|
+
|
|
1099
|
+
it('preserves inline styles on slotted elements', () => {
|
|
1100
|
+
component('slot-style', {
|
|
1101
|
+
render() { return '<div><slot name="styled">default</slot></div>'; },
|
|
1102
|
+
});
|
|
1103
|
+
document.body.innerHTML = '<slot-style id="sst"><span slot="styled" style="color: red;">Red</span></slot-style>';
|
|
1104
|
+
mount('#sst', 'slot-style');
|
|
1105
|
+
const span = document.querySelector('#sst span');
|
|
1106
|
+
expect(span.style.color).toBe('red');
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// --- Mixed text and elements in default slot ---
|
|
1110
|
+
|
|
1111
|
+
it('projects mixed text and element nodes', () => {
|
|
1112
|
+
component('slot-mixedcontent', {
|
|
1113
|
+
render() { return '<div><slot></slot></div>'; },
|
|
1114
|
+
});
|
|
1115
|
+
document.body.innerHTML = '<slot-mixedcontent id="smc">Text before <strong>bold</strong> text after</slot-mixedcontent>';
|
|
1116
|
+
mount('#smc', 'slot-mixedcontent');
|
|
1117
|
+
const div = document.querySelector('#smc div');
|
|
1118
|
+
expect(div.textContent).toContain('Text before');
|
|
1119
|
+
expect(div.textContent).toContain('bold');
|
|
1120
|
+
expect(div.textContent).toContain('text after');
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
// --- Comment nodes should be ignored ---
|
|
1124
|
+
|
|
1125
|
+
it('ignores comment nodes in slot content', () => {
|
|
1126
|
+
component('slot-comments', {
|
|
1127
|
+
render() { return '<div><slot>fallback</slot></div>'; },
|
|
1128
|
+
});
|
|
1129
|
+
const el = document.createElement('slot-comments');
|
|
1130
|
+
el.id = 'scm';
|
|
1131
|
+
el.appendChild(document.createComment('this is a comment'));
|
|
1132
|
+
document.body.appendChild(el);
|
|
1133
|
+
mount('#scm', 'slot-comments');
|
|
1134
|
+
// Only a comment — should use fallback
|
|
1135
|
+
expect(document.querySelector('#scm div').textContent).toBe('fallback');
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it('ignores comments but still captures sibling elements', () => {
|
|
1139
|
+
component('slot-comments-mix', {
|
|
1140
|
+
render() { return '<div><slot>fallback</slot></div>'; },
|
|
1141
|
+
});
|
|
1142
|
+
const el = document.createElement('slot-comments-mix');
|
|
1143
|
+
el.id = 'scmm';
|
|
1144
|
+
el.appendChild(document.createComment('comment'));
|
|
1145
|
+
const p = document.createElement('p');
|
|
1146
|
+
p.textContent = 'Real content';
|
|
1147
|
+
el.appendChild(p);
|
|
1148
|
+
document.body.appendChild(el);
|
|
1149
|
+
mount('#scmm', 'slot-comments-mix');
|
|
1150
|
+
expect(document.querySelector('#scmm p').textContent).toBe('Real content');
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// --- Slot with fallback containing HTML ---
|
|
1154
|
+
|
|
1155
|
+
it('renders rich HTML fallback when no content provided', () => {
|
|
1156
|
+
component('slot-richfb', {
|
|
1157
|
+
render() { return '<div><slot><em>No content</em> provided</slot></div>'; },
|
|
1158
|
+
});
|
|
1159
|
+
document.body.innerHTML = '<slot-richfb id="srf"></slot-richfb>';
|
|
1160
|
+
mount('#srf', 'slot-richfb');
|
|
1161
|
+
expect(document.querySelector('#srf em').textContent).toBe('No content');
|
|
1162
|
+
expect(document.querySelector('#srf div').textContent).toContain('provided');
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it('replaces rich fallback when content is provided', () => {
|
|
1166
|
+
component('slot-richfb-replace', {
|
|
1167
|
+
render() { return '<div><slot><em>Fallback</em></slot></div>'; },
|
|
1168
|
+
});
|
|
1169
|
+
document.body.innerHTML = '<slot-richfb-replace id="srfr"><p>Real</p></slot-richfb-replace>';
|
|
1170
|
+
mount('#srfr', 'slot-richfb-replace');
|
|
1171
|
+
expect(document.querySelector('#srfr em')).toBeNull();
|
|
1172
|
+
expect(document.querySelector('#srfr p').textContent).toBe('Real');
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
// --- Named slot fallback with HTML ---
|
|
1176
|
+
|
|
1177
|
+
it('uses rich fallback for named slot when unmatched', () => {
|
|
1178
|
+
component('slot-namedfb-rich', {
|
|
1179
|
+
render() { return '<div><slot name="info"><span class="default">Default Info</span></slot></div>'; },
|
|
1180
|
+
});
|
|
1181
|
+
document.body.innerHTML = '<slot-namedfb-rich id="snfbr"></slot-namedfb-rich>';
|
|
1182
|
+
mount('#snfbr', 'slot-namedfb-rich');
|
|
1183
|
+
expect(document.querySelector('#snfbr .default').textContent).toBe('Default Info');
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
// --- Slot content with z- attributes should not be processed as directives ---
|
|
1187
|
+
|
|
1188
|
+
it('slot projected content is static (outerHTML snapshot)', () => {
|
|
1189
|
+
component('slot-static', {
|
|
1190
|
+
state: () => ({ label: 'Label' }),
|
|
1191
|
+
render() { return '<div><slot></slot><p>State: ${this.state.label}</p></div>'; },
|
|
1192
|
+
});
|
|
1193
|
+
document.body.innerHTML = '<slot-static id="ssta"><span data-info="test">Static span</span></slot-static>';
|
|
1194
|
+
mount('#ssta', 'slot-static');
|
|
1195
|
+
expect(document.querySelector('#ssta span').textContent).toBe('Static span');
|
|
1196
|
+
expect(document.querySelector('#ssta span').getAttribute('data-info')).toBe('test');
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
// --- Default slot with only named content (no default → fallback) ---
|
|
1200
|
+
|
|
1201
|
+
it('uses default slot fallback when all child content is named', () => {
|
|
1202
|
+
component('slot-allnamed', {
|
|
1203
|
+
render() {
|
|
1204
|
+
return '<header><slot name="header">H</slot></header><main><slot>Default body</slot></main>';
|
|
1205
|
+
},
|
|
1206
|
+
});
|
|
1207
|
+
document.body.innerHTML = '<slot-allnamed id="san"><div slot="header">Title</div></slot-allnamed>';
|
|
1208
|
+
mount('#san', 'slot-allnamed');
|
|
1209
|
+
expect(document.querySelector('#san header').textContent).toBe('Title');
|
|
1210
|
+
expect(document.querySelector('#san main').textContent).toBe('Default body');
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
// --- Empty component (no children at all) ---
|
|
1214
|
+
|
|
1215
|
+
it('uses all fallbacks when component has no children', () => {
|
|
1216
|
+
component('slot-empty', {
|
|
1217
|
+
render() {
|
|
1218
|
+
return '<header><slot name="h">FH</slot></header><main><slot>FM</slot></main><footer><slot name="f">FF</slot></footer>';
|
|
1219
|
+
},
|
|
1220
|
+
});
|
|
1221
|
+
document.body.innerHTML = '<slot-empty id="sem"></slot-empty>';
|
|
1222
|
+
mount('#sem', 'slot-empty');
|
|
1223
|
+
expect(document.querySelector('#sem header').textContent).toBe('FH');
|
|
1224
|
+
expect(document.querySelector('#sem main').textContent).toBe('FM');
|
|
1225
|
+
expect(document.querySelector('#sem footer').textContent).toBe('FF');
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
// --- BUG: slot="" (empty string) should map to default slot ---
|
|
1229
|
+
|
|
1230
|
+
it('treats slot="" as the default slot', () => {
|
|
1231
|
+
component('slot-emptyattr', {
|
|
1232
|
+
render() { return '<div><slot>fallback</slot></div>'; },
|
|
1233
|
+
});
|
|
1234
|
+
document.body.innerHTML = '<slot-emptyattr id="sea"><p slot="">Empty attr</p></slot-emptyattr>';
|
|
1235
|
+
mount('#sea', 'slot-emptyattr');
|
|
1236
|
+
expect(document.querySelector('#sea p').textContent).toBe('Empty attr');
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
it('treats bare slot attribute (no value) as default slot', () => {
|
|
1240
|
+
component('slot-bareattr', {
|
|
1241
|
+
render() { return '<div><slot>fallback</slot></div>'; },
|
|
1242
|
+
});
|
|
1243
|
+
const el = document.createElement('slot-bareattr');
|
|
1244
|
+
el.id = 'sba';
|
|
1245
|
+
const p = document.createElement('p');
|
|
1246
|
+
p.textContent = 'Bare attr';
|
|
1247
|
+
p.setAttribute('slot', '');
|
|
1248
|
+
el.appendChild(p);
|
|
1249
|
+
document.body.appendChild(el);
|
|
1250
|
+
mount('#sba', 'slot-bareattr');
|
|
1251
|
+
expect(document.querySelector('#sba p').textContent).toBe('Bare attr');
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
// --- Slot content with template literal expressions should be static ---
|
|
1255
|
+
|
|
1256
|
+
it('does not evaluate expressions in projected slot content', () => {
|
|
1257
|
+
component('slot-noeval', {
|
|
1258
|
+
state: () => ({ x: 'STATE' }),
|
|
1259
|
+
render() { return '<div><slot></slot></div>'; },
|
|
1260
|
+
});
|
|
1261
|
+
document.body.innerHTML = '<slot-noeval id="sne2"><p>${this.state.x}</p></slot-noeval>';
|
|
1262
|
+
mount('#sne2', 'slot-noeval');
|
|
1263
|
+
// The projected content is raw HTML, not evaluated as a template expression
|
|
1264
|
+
expect(document.querySelector('#sne2 p').textContent).toBe('${this.state.x}');
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
// --- Multiple re-renders preserve slots ---
|
|
1268
|
+
|
|
1269
|
+
it('preserves slot content after multiple state changes', async () => {
|
|
1270
|
+
component('slot-multirerender', {
|
|
1271
|
+
state: () => ({ n: 0 }),
|
|
1272
|
+
render() {
|
|
1273
|
+
return `<span>${this.state.n}</span><div class="slot"><slot>fb</slot></div>`;
|
|
1274
|
+
},
|
|
1275
|
+
});
|
|
1276
|
+
document.body.innerHTML = '<slot-multirerender id="smrr"><b>Slot!</b></slot-multirerender>';
|
|
1277
|
+
const inst = mount('#smrr', 'slot-multirerender');
|
|
1278
|
+
expect(document.querySelector('#smrr .slot b').textContent).toBe('Slot!');
|
|
1279
|
+
|
|
1280
|
+
for (let i = 1; i <= 5; i++) {
|
|
1281
|
+
inst.state.n = i;
|
|
1282
|
+
await new Promise(r => queueMicrotask(r));
|
|
1283
|
+
expect(document.querySelector('#smrr span').textContent).toBe(String(i));
|
|
1284
|
+
expect(document.querySelector('#smrr .slot b').textContent).toBe('Slot!');
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
// --- Named slot with extra whitespace in template ---
|
|
1289
|
+
|
|
1290
|
+
it('handles extra whitespace around slot name attribute', () => {
|
|
1291
|
+
component('slot-wsname', {
|
|
1292
|
+
render() { return '<div><slot name="hd" >fallback</slot></div>'; },
|
|
1293
|
+
});
|
|
1294
|
+
document.body.innerHTML = '<slot-wsname id="swn"><span slot="hd">HdContent</span></slot-wsname>';
|
|
1295
|
+
mount('#swn', 'slot-wsname');
|
|
1296
|
+
expect(document.querySelector('#swn span').textContent).toBe('HdContent');
|
|
1297
|
+
});
|
|
867
1298
|
});
|
|
868
1299
|
|
|
869
1300
|
|
|
@@ -1587,6 +2018,145 @@ describe('component — z-model contenteditable', () => {
|
|
|
1587
2018
|
});
|
|
1588
2019
|
});
|
|
1589
2020
|
|
|
2021
|
+
|
|
2022
|
+
// ===========================================================================
|
|
2023
|
+
// z-model z-debounce modifier
|
|
2024
|
+
// ===========================================================================
|
|
2025
|
+
|
|
2026
|
+
describe('component — z-model z-debounce modifier', () => {
|
|
2027
|
+
it('delays state update by the specified ms', () => {
|
|
2028
|
+
vi.useFakeTimers();
|
|
2029
|
+
component('zmodel-debounce', {
|
|
2030
|
+
state: () => ({ search: '' }),
|
|
2031
|
+
render() { return '<input z-model="search" z-debounce="200">'; },
|
|
2032
|
+
});
|
|
2033
|
+
document.body.innerHTML = '<zmodel-debounce id="zmd"></zmodel-debounce>';
|
|
2034
|
+
const inst = mount('#zmd', 'zmodel-debounce');
|
|
2035
|
+
const input = document.querySelector('#zmd input');
|
|
2036
|
+
|
|
2037
|
+
input.value = 'hel';
|
|
2038
|
+
input.dispatchEvent(new Event('input'));
|
|
2039
|
+
expect(inst.state.search).toBe(''); // Not yet
|
|
2040
|
+
|
|
2041
|
+
vi.advanceTimersByTime(100);
|
|
2042
|
+
input.value = 'hello';
|
|
2043
|
+
input.dispatchEvent(new Event('input'));
|
|
2044
|
+
expect(inst.state.search).toBe(''); // Timer restarted
|
|
2045
|
+
|
|
2046
|
+
vi.advanceTimersByTime(200);
|
|
2047
|
+
expect(inst.state.search).toBe('hello'); // Now fires
|
|
2048
|
+
|
|
2049
|
+
vi.useRealTimers();
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
it('defaults to 250ms when no value is specified', () => {
|
|
2053
|
+
vi.useFakeTimers();
|
|
2054
|
+
component('zmodel-debounce-def', {
|
|
2055
|
+
state: () => ({ q: '' }),
|
|
2056
|
+
render() { return '<input z-model="q" z-debounce>'; },
|
|
2057
|
+
});
|
|
2058
|
+
document.body.innerHTML = '<zmodel-debounce-def id="zmdd"></zmodel-debounce-def>';
|
|
2059
|
+
const inst = mount('#zmdd', 'zmodel-debounce-def');
|
|
2060
|
+
const input = document.querySelector('#zmdd input');
|
|
2061
|
+
|
|
2062
|
+
input.value = 'test';
|
|
2063
|
+
input.dispatchEvent(new Event('input'));
|
|
2064
|
+
expect(inst.state.q).toBe('');
|
|
2065
|
+
|
|
2066
|
+
vi.advanceTimersByTime(249);
|
|
2067
|
+
expect(inst.state.q).toBe('');
|
|
2068
|
+
|
|
2069
|
+
vi.advanceTimersByTime(1);
|
|
2070
|
+
expect(inst.state.q).toBe('test');
|
|
2071
|
+
|
|
2072
|
+
vi.useRealTimers();
|
|
2073
|
+
});
|
|
2074
|
+
|
|
2075
|
+
it('works alongside z-trim', () => {
|
|
2076
|
+
vi.useFakeTimers();
|
|
2077
|
+
component('zmodel-debounce-trim', {
|
|
2078
|
+
state: () => ({ val: '' }),
|
|
2079
|
+
render() { return '<input z-model="val" z-debounce="100" z-trim>'; },
|
|
2080
|
+
});
|
|
2081
|
+
document.body.innerHTML = '<zmodel-debounce-trim id="zmdt"></zmodel-debounce-trim>';
|
|
2082
|
+
const inst = mount('#zmdt', 'zmodel-debounce-trim');
|
|
2083
|
+
const input = document.querySelector('#zmdt input');
|
|
2084
|
+
|
|
2085
|
+
input.value = ' hello ';
|
|
2086
|
+
input.dispatchEvent(new Event('input'));
|
|
2087
|
+
vi.advanceTimersByTime(100);
|
|
2088
|
+
expect(inst.state.val).toBe('hello');
|
|
2089
|
+
|
|
2090
|
+
vi.useRealTimers();
|
|
2091
|
+
});
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
|
|
2095
|
+
// ===========================================================================
|
|
2096
|
+
// z-model z-uppercase / z-lowercase modifiers
|
|
2097
|
+
// ===========================================================================
|
|
2098
|
+
|
|
2099
|
+
describe('component — z-model z-uppercase modifier', () => {
|
|
2100
|
+
it('converts input value to uppercase before writing to state', () => {
|
|
2101
|
+
component('zmodel-upper', {
|
|
2102
|
+
state: () => ({ val: '' }),
|
|
2103
|
+
render() { return '<input z-model="val" z-uppercase>'; },
|
|
2104
|
+
});
|
|
2105
|
+
document.body.innerHTML = '<zmodel-upper id="zmu"></zmodel-upper>';
|
|
2106
|
+
const inst = mount('#zmu', 'zmodel-upper');
|
|
2107
|
+
const input = document.querySelector('#zmu input');
|
|
2108
|
+
|
|
2109
|
+
input.value = 'hello world';
|
|
2110
|
+
input.dispatchEvent(new Event('input'));
|
|
2111
|
+
expect(inst.state.val).toBe('HELLO WORLD');
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
it('works with z-trim', () => {
|
|
2115
|
+
component('zmodel-upper-trim', {
|
|
2116
|
+
state: () => ({ val: '' }),
|
|
2117
|
+
render() { return '<input z-model="val" z-uppercase z-trim>'; },
|
|
2118
|
+
});
|
|
2119
|
+
document.body.innerHTML = '<zmodel-upper-trim id="zmut"></zmodel-upper-trim>';
|
|
2120
|
+
const inst = mount('#zmut', 'zmodel-upper-trim');
|
|
2121
|
+
const input = document.querySelector('#zmut input');
|
|
2122
|
+
|
|
2123
|
+
input.value = ' hello ';
|
|
2124
|
+
input.dispatchEvent(new Event('input'));
|
|
2125
|
+
expect(inst.state.val).toBe('HELLO');
|
|
2126
|
+
});
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
describe('component — z-model z-lowercase modifier', () => {
|
|
2130
|
+
it('converts input value to lowercase before writing to state', () => {
|
|
2131
|
+
component('zmodel-lower', {
|
|
2132
|
+
state: () => ({ val: '' }),
|
|
2133
|
+
render() { return '<input z-model="val" z-lowercase>'; },
|
|
2134
|
+
});
|
|
2135
|
+
document.body.innerHTML = '<zmodel-lower id="zmlw"></zmodel-lower>';
|
|
2136
|
+
const inst = mount('#zmlw', 'zmodel-lower');
|
|
2137
|
+
const input = document.querySelector('#zmlw input');
|
|
2138
|
+
|
|
2139
|
+
input.value = 'HELLO World';
|
|
2140
|
+
input.dispatchEvent(new Event('input'));
|
|
2141
|
+
expect(inst.state.val).toBe('hello world');
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
it('works on textarea', () => {
|
|
2145
|
+
component('zmodel-lower-ta', {
|
|
2146
|
+
state: () => ({ val: '' }),
|
|
2147
|
+
render() { return '<textarea z-model="val" z-lowercase></textarea>'; },
|
|
2148
|
+
});
|
|
2149
|
+
document.body.innerHTML = '<zmodel-lower-ta id="zmlt"></zmodel-lower-ta>';
|
|
2150
|
+
const inst = mount('#zmlt', 'zmodel-lower-ta');
|
|
2151
|
+
const ta = document.querySelector('#zmlt textarea');
|
|
2152
|
+
|
|
2153
|
+
ta.value = 'MiXeD CaSe';
|
|
2154
|
+
ta.dispatchEvent(new Event('input'));
|
|
2155
|
+
expect(inst.state.val).toBe('mixed case');
|
|
2156
|
+
});
|
|
2157
|
+
});
|
|
2158
|
+
|
|
2159
|
+
|
|
1590
2160
|
describe('component — z-model radio', () => {
|
|
1591
2161
|
it('checks the radio matching state value and writes back on change', () => {
|
|
1592
2162
|
component('zmodel-radio', {
|
|
@@ -1858,6 +2428,376 @@ describe('component — combined event modifiers', () => {
|
|
|
1858
2428
|
|
|
1859
2429
|
|
|
1860
2430
|
// ===========================================================================
|
|
2431
|
+
// Key modifiers — .enter, .escape, .tab, .space, .delete, .up, .down, .left, .right
|
|
2432
|
+
// ===========================================================================
|
|
2433
|
+
|
|
2434
|
+
describe('component — key modifier .enter', () => {
|
|
2435
|
+
it('fires handler only on Enter key', () => {
|
|
2436
|
+
let count = 0;
|
|
2437
|
+
component('key-enter', {
|
|
2438
|
+
handler() { count++; },
|
|
2439
|
+
render() { return '<input @keydown.enter="handler">'; },
|
|
2440
|
+
});
|
|
2441
|
+
document.body.innerHTML = '<key-enter id="ke"></key-enter>';
|
|
2442
|
+
mount('#ke', 'key-enter');
|
|
2443
|
+
const input = document.querySelector('#ke input');
|
|
2444
|
+
|
|
2445
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
2446
|
+
expect(count).toBe(1);
|
|
2447
|
+
|
|
2448
|
+
// Other keys should NOT fire
|
|
2449
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
|
|
2450
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
|
2451
|
+
expect(count).toBe(1);
|
|
2452
|
+
});
|
|
2453
|
+
});
|
|
2454
|
+
|
|
2455
|
+
describe('component — key modifier .escape', () => {
|
|
2456
|
+
it('fires handler only on Escape key', () => {
|
|
2457
|
+
let count = 0;
|
|
2458
|
+
component('key-esc', {
|
|
2459
|
+
handler() { count++; },
|
|
2460
|
+
render() { return '<input @keydown.escape="handler">'; },
|
|
2461
|
+
});
|
|
2462
|
+
document.body.innerHTML = '<key-esc id="kesc"></key-esc>';
|
|
2463
|
+
mount('#kesc', 'key-esc');
|
|
2464
|
+
const input = document.querySelector('#kesc input');
|
|
2465
|
+
|
|
2466
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
2467
|
+
expect(count).toBe(1);
|
|
2468
|
+
|
|
2469
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
2470
|
+
expect(count).toBe(1);
|
|
2471
|
+
});
|
|
2472
|
+
});
|
|
2473
|
+
|
|
2474
|
+
describe('component — key modifier .tab', () => {
|
|
2475
|
+
it('fires handler only on Tab key', () => {
|
|
2476
|
+
let count = 0;
|
|
2477
|
+
component('key-tab', {
|
|
2478
|
+
handler() { count++; },
|
|
2479
|
+
render() { return '<input @keydown.tab="handler">'; },
|
|
2480
|
+
});
|
|
2481
|
+
document.body.innerHTML = '<key-tab id="ktab"></key-tab>';
|
|
2482
|
+
mount('#ktab', 'key-tab');
|
|
2483
|
+
const input = document.querySelector('#ktab input');
|
|
2484
|
+
|
|
2485
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
|
2486
|
+
expect(count).toBe(1);
|
|
2487
|
+
|
|
2488
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
2489
|
+
expect(count).toBe(1);
|
|
2490
|
+
});
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
describe('component — key modifier .space', () => {
|
|
2494
|
+
it('fires handler only on Space key', () => {
|
|
2495
|
+
let count = 0;
|
|
2496
|
+
component('key-space', {
|
|
2497
|
+
handler() { count++; },
|
|
2498
|
+
render() { return '<button @keydown.space="handler">btn</button>'; },
|
|
2499
|
+
});
|
|
2500
|
+
document.body.innerHTML = '<key-space id="kspc"></key-space>';
|
|
2501
|
+
mount('#kspc', 'key-space');
|
|
2502
|
+
const btn = document.querySelector('#kspc button');
|
|
2503
|
+
|
|
2504
|
+
btn.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
|
|
2505
|
+
expect(count).toBe(1);
|
|
2506
|
+
|
|
2507
|
+
btn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
2508
|
+
expect(count).toBe(1);
|
|
2509
|
+
});
|
|
2510
|
+
});
|
|
2511
|
+
|
|
2512
|
+
describe('component — key modifier .delete', () => {
|
|
2513
|
+
it('fires handler on Delete and Backspace keys', () => {
|
|
2514
|
+
let count = 0;
|
|
2515
|
+
component('key-del', {
|
|
2516
|
+
handler() { count++; },
|
|
2517
|
+
render() { return '<input @keydown.delete="handler">'; },
|
|
2518
|
+
});
|
|
2519
|
+
document.body.innerHTML = '<key-del id="kdel"></key-del>';
|
|
2520
|
+
mount('#kdel', 'key-del');
|
|
2521
|
+
const input = document.querySelector('#kdel input');
|
|
2522
|
+
|
|
2523
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
|
2524
|
+
expect(count).toBe(1);
|
|
2525
|
+
|
|
2526
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
|
|
2527
|
+
expect(count).toBe(2);
|
|
2528
|
+
|
|
2529
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
|
|
2530
|
+
expect(count).toBe(2);
|
|
2531
|
+
});
|
|
2532
|
+
});
|
|
2533
|
+
|
|
2534
|
+
describe('component — key modifier arrow keys', () => {
|
|
2535
|
+
it('.up fires only on ArrowUp', () => {
|
|
2536
|
+
let count = 0;
|
|
2537
|
+
component('key-up', {
|
|
2538
|
+
handler() { count++; },
|
|
2539
|
+
render() { return '<input @keydown.up="handler">'; },
|
|
2540
|
+
});
|
|
2541
|
+
document.body.innerHTML = '<key-up id="kup"></key-up>';
|
|
2542
|
+
mount('#kup', 'key-up');
|
|
2543
|
+
const input = document.querySelector('#kup input');
|
|
2544
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
|
2545
|
+
expect(count).toBe(1);
|
|
2546
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
2547
|
+
expect(count).toBe(1);
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
it('.down fires only on ArrowDown', () => {
|
|
2551
|
+
let count = 0;
|
|
2552
|
+
component('key-down', {
|
|
2553
|
+
handler() { count++; },
|
|
2554
|
+
render() { return '<input @keydown.down="handler">'; },
|
|
2555
|
+
});
|
|
2556
|
+
document.body.innerHTML = '<key-down id="kdn"></key-down>';
|
|
2557
|
+
mount('#kdn', 'key-down');
|
|
2558
|
+
const input = document.querySelector('#kdn input');
|
|
2559
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
2560
|
+
expect(count).toBe(1);
|
|
2561
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
|
2562
|
+
expect(count).toBe(1);
|
|
2563
|
+
});
|
|
2564
|
+
|
|
2565
|
+
it('.left fires only on ArrowLeft', () => {
|
|
2566
|
+
let count = 0;
|
|
2567
|
+
component('key-left', {
|
|
2568
|
+
handler() { count++; },
|
|
2569
|
+
render() { return '<input @keydown.left="handler">'; },
|
|
2570
|
+
});
|
|
2571
|
+
document.body.innerHTML = '<key-left id="klf"></key-left>';
|
|
2572
|
+
mount('#klf', 'key-left');
|
|
2573
|
+
const input = document.querySelector('#klf input');
|
|
2574
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
|
2575
|
+
expect(count).toBe(1);
|
|
2576
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
2577
|
+
expect(count).toBe(1);
|
|
2578
|
+
});
|
|
2579
|
+
|
|
2580
|
+
it('.right fires only on ArrowRight', () => {
|
|
2581
|
+
let count = 0;
|
|
2582
|
+
component('key-right', {
|
|
2583
|
+
handler() { count++; },
|
|
2584
|
+
render() { return '<input @keydown.right="handler">'; },
|
|
2585
|
+
});
|
|
2586
|
+
document.body.innerHTML = '<key-right id="krt"></key-right>';
|
|
2587
|
+
mount('#krt', 'key-right');
|
|
2588
|
+
const input = document.querySelector('#krt input');
|
|
2589
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
2590
|
+
expect(count).toBe(1);
|
|
2591
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
|
2592
|
+
expect(count).toBe(1);
|
|
2593
|
+
});
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
describe('component — key modifiers combined with other modifiers', () => {
|
|
2597
|
+
it('.enter.prevent prevents default only on Enter', () => {
|
|
2598
|
+
component('key-enter-prev', {
|
|
2599
|
+
handler() {},
|
|
2600
|
+
render() { return '<input @keydown.enter.prevent="handler">'; },
|
|
2601
|
+
});
|
|
2602
|
+
document.body.innerHTML = '<key-enter-prev id="kep"></key-enter-prev>';
|
|
2603
|
+
mount('#kep', 'key-enter-prev');
|
|
2604
|
+
const input = document.querySelector('#kep input');
|
|
2605
|
+
|
|
2606
|
+
const enterEvt = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
|
2607
|
+
vi.spyOn(enterEvt, 'preventDefault');
|
|
2608
|
+
input.dispatchEvent(enterEvt);
|
|
2609
|
+
expect(enterEvt.preventDefault).toHaveBeenCalled();
|
|
2610
|
+
|
|
2611
|
+
// Other key should not trigger handler or preventDefault
|
|
2612
|
+
const tabEvt = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
|
2613
|
+
vi.spyOn(tabEvt, 'preventDefault');
|
|
2614
|
+
input.dispatchEvent(tabEvt);
|
|
2615
|
+
expect(tabEvt.preventDefault).not.toHaveBeenCalled();
|
|
2616
|
+
});
|
|
2617
|
+
});
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
|
+
// ===========================================================================
|
|
2621
|
+
// System key modifiers — .ctrl, .shift, .alt, .meta
|
|
2622
|
+
// ===========================================================================
|
|
2623
|
+
|
|
2624
|
+
describe('component — system key modifier .ctrl', () => {
|
|
2625
|
+
it('fires only when Ctrl is held', () => {
|
|
2626
|
+
let count = 0;
|
|
2627
|
+
component('sys-ctrl', {
|
|
2628
|
+
handler() { count++; },
|
|
2629
|
+
render() { return '<input @keydown.ctrl="handler">'; },
|
|
2630
|
+
});
|
|
2631
|
+
document.body.innerHTML = '<sys-ctrl id="sc"></sys-ctrl>';
|
|
2632
|
+
mount('#sc', 'sys-ctrl');
|
|
2633
|
+
const input = document.querySelector('#sc input');
|
|
2634
|
+
|
|
2635
|
+
// Without Ctrl — should NOT fire
|
|
2636
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, ctrlKey: false }));
|
|
2637
|
+
expect(count).toBe(0);
|
|
2638
|
+
|
|
2639
|
+
// With Ctrl — should fire
|
|
2640
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, ctrlKey: true }));
|
|
2641
|
+
expect(count).toBe(1);
|
|
2642
|
+
});
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
describe('component — system key modifier .shift', () => {
|
|
2646
|
+
it('fires only when Shift is held', () => {
|
|
2647
|
+
let count = 0;
|
|
2648
|
+
component('sys-shift', {
|
|
2649
|
+
handler() { count++; },
|
|
2650
|
+
render() { return '<input @keydown.shift="handler">'; },
|
|
2651
|
+
});
|
|
2652
|
+
document.body.innerHTML = '<sys-shift id="ss"></sys-shift>';
|
|
2653
|
+
mount('#ss', 'sys-shift');
|
|
2654
|
+
const input = document.querySelector('#ss input');
|
|
2655
|
+
|
|
2656
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, shiftKey: false }));
|
|
2657
|
+
expect(count).toBe(0);
|
|
2658
|
+
|
|
2659
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, shiftKey: true }));
|
|
2660
|
+
expect(count).toBe(1);
|
|
2661
|
+
});
|
|
2662
|
+
});
|
|
2663
|
+
|
|
2664
|
+
describe('component — system key modifier .alt', () => {
|
|
2665
|
+
it('fires only when Alt is held', () => {
|
|
2666
|
+
let count = 0;
|
|
2667
|
+
component('sys-alt', {
|
|
2668
|
+
handler() { count++; },
|
|
2669
|
+
render() { return '<input @keydown.alt="handler">'; },
|
|
2670
|
+
});
|
|
2671
|
+
document.body.innerHTML = '<sys-alt id="sa"></sys-alt>';
|
|
2672
|
+
mount('#sa', 'sys-alt');
|
|
2673
|
+
const input = document.querySelector('#sa input');
|
|
2674
|
+
|
|
2675
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, altKey: false }));
|
|
2676
|
+
expect(count).toBe(0);
|
|
2677
|
+
|
|
2678
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, altKey: true }));
|
|
2679
|
+
expect(count).toBe(1);
|
|
2680
|
+
});
|
|
2681
|
+
});
|
|
2682
|
+
|
|
2683
|
+
describe('component — system key modifier .meta', () => {
|
|
2684
|
+
it('fires only when Meta (Cmd/Win) is held', () => {
|
|
2685
|
+
let count = 0;
|
|
2686
|
+
component('sys-meta', {
|
|
2687
|
+
handler() { count++; },
|
|
2688
|
+
render() { return '<input @keydown.meta="handler">'; },
|
|
2689
|
+
});
|
|
2690
|
+
document.body.innerHTML = '<sys-meta id="sm"></sys-meta>';
|
|
2691
|
+
mount('#sm', 'sys-meta');
|
|
2692
|
+
const input = document.querySelector('#sm input');
|
|
2693
|
+
|
|
2694
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, metaKey: false }));
|
|
2695
|
+
expect(count).toBe(0);
|
|
2696
|
+
|
|
2697
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, metaKey: true }));
|
|
2698
|
+
expect(count).toBe(1);
|
|
2699
|
+
});
|
|
2700
|
+
});
|
|
2701
|
+
|
|
2702
|
+
describe('component — combined key + system modifiers', () => {
|
|
2703
|
+
it('.ctrl.enter fires only on Ctrl+Enter', () => {
|
|
2704
|
+
let count = 0;
|
|
2705
|
+
component('sys-ctrl-enter', {
|
|
2706
|
+
handler() { count++; },
|
|
2707
|
+
render() { return '<textarea @keydown.ctrl.enter="handler"></textarea>'; },
|
|
2708
|
+
});
|
|
2709
|
+
document.body.innerHTML = '<sys-ctrl-enter id="sce"></sys-ctrl-enter>';
|
|
2710
|
+
mount('#sce', 'sys-ctrl-enter');
|
|
2711
|
+
const ta = document.querySelector('#sce textarea');
|
|
2712
|
+
|
|
2713
|
+
// Enter without Ctrl
|
|
2714
|
+
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, ctrlKey: false }));
|
|
2715
|
+
expect(count).toBe(0);
|
|
2716
|
+
|
|
2717
|
+
// Ctrl without Enter
|
|
2718
|
+
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, ctrlKey: true }));
|
|
2719
|
+
expect(count).toBe(0);
|
|
2720
|
+
|
|
2721
|
+
// Ctrl+Enter
|
|
2722
|
+
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, ctrlKey: true }));
|
|
2723
|
+
expect(count).toBe(1);
|
|
2724
|
+
});
|
|
2725
|
+
|
|
2726
|
+
it('.shift.enter fires only on Shift+Enter', () => {
|
|
2727
|
+
let count = 0;
|
|
2728
|
+
component('sys-shift-enter', {
|
|
2729
|
+
handler() { count++; },
|
|
2730
|
+
render() { return '<textarea @keydown.shift.enter="handler"></textarea>'; },
|
|
2731
|
+
});
|
|
2732
|
+
document.body.innerHTML = '<sys-shift-enter id="sse"></sys-shift-enter>';
|
|
2733
|
+
mount('#sse', 'sys-shift-enter');
|
|
2734
|
+
const ta = document.querySelector('#sse textarea');
|
|
2735
|
+
|
|
2736
|
+
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, shiftKey: false }));
|
|
2737
|
+
expect(count).toBe(0);
|
|
2738
|
+
|
|
2739
|
+
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, shiftKey: true }));
|
|
2740
|
+
expect(count).toBe(1);
|
|
2741
|
+
});
|
|
2742
|
+
});
|
|
2743
|
+
|
|
2744
|
+
|
|
2745
|
+
// ===========================================================================
|
|
2746
|
+
// .outside modifier — fire when event target is outside the element
|
|
2747
|
+
// ===========================================================================
|
|
2748
|
+
|
|
2749
|
+
describe('component — .outside event modifier', () => {
|
|
2750
|
+
it('fires handler when clicking outside the element', () => {
|
|
2751
|
+
let count = 0;
|
|
2752
|
+
component('evt-outside', {
|
|
2753
|
+
close() { count++; },
|
|
2754
|
+
render() { return '<div class="dropdown" @click.outside="close"><span>menu</span></div>'; },
|
|
2755
|
+
});
|
|
2756
|
+
document.body.innerHTML = '<evt-outside id="eo"></evt-outside>';
|
|
2757
|
+
mount('#eo', 'evt-outside');
|
|
2758
|
+
|
|
2759
|
+
// Click inside the dropdown — should NOT fire
|
|
2760
|
+
document.querySelector('#eo .dropdown').click();
|
|
2761
|
+
expect(count).toBe(0);
|
|
2762
|
+
|
|
2763
|
+
// Click inside a child — should NOT fire
|
|
2764
|
+
document.querySelector('#eo span').click();
|
|
2765
|
+
expect(count).toBe(0);
|
|
2766
|
+
|
|
2767
|
+
// Click on the component root (outside the dropdown) — should fire
|
|
2768
|
+
document.querySelector('#eo').dispatchEvent(new Event('click', { bubbles: true }));
|
|
2769
|
+
expect(count).toBe(1);
|
|
2770
|
+
});
|
|
2771
|
+
|
|
2772
|
+
it('fires handler when clicking on document body (outside component)', () => {
|
|
2773
|
+
let count = 0;
|
|
2774
|
+
component('evt-outside2', {
|
|
2775
|
+
close() { count++; },
|
|
2776
|
+
render() { return '<div class="modal" @click.outside="close">modal content</div>'; },
|
|
2777
|
+
});
|
|
2778
|
+
document.body.innerHTML = '<div id="other">other</div><evt-outside2 id="eo2"></evt-outside2>';
|
|
2779
|
+
mount('#eo2', 'evt-outside2');
|
|
2780
|
+
|
|
2781
|
+
// Click on unrelated element
|
|
2782
|
+
document.querySelector('#other').dispatchEvent(new Event('click', { bubbles: true }));
|
|
2783
|
+
expect(count).toBe(1);
|
|
2784
|
+
});
|
|
2785
|
+
|
|
2786
|
+
it('does not fire on click inside the element', () => {
|
|
2787
|
+
let count = 0;
|
|
2788
|
+
component('evt-outside3', {
|
|
2789
|
+
close() { count++; },
|
|
2790
|
+
render() { return '<div class="panel" @click.outside="close"><button>inside</button></div>'; },
|
|
2791
|
+
});
|
|
2792
|
+
document.body.innerHTML = '<evt-outside3 id="eo3"></evt-outside3>';
|
|
2793
|
+
mount('#eo3', 'evt-outside3');
|
|
2794
|
+
|
|
2795
|
+
document.querySelector('#eo3 button').click();
|
|
2796
|
+
expect(count).toBe(0);
|
|
2797
|
+
document.querySelector('#eo3 .panel').click();
|
|
2798
|
+
expect(count).toBe(0);
|
|
2799
|
+
});
|
|
2800
|
+
});
|
|
1861
2801
|
// z-for advanced — object, iterable, range, null
|
|
1862
2802
|
// ===========================================================================
|
|
1863
2803
|
|
|
@@ -2271,3 +3211,248 @@ describe('component — multiple instances of same component', () => {
|
|
|
2271
3211
|
expect(inst2.state.val).toBe(0);
|
|
2272
3212
|
});
|
|
2273
3213
|
});
|
|
3214
|
+
|
|
3215
|
+
|
|
3216
|
+
// ===========================================================================
|
|
3217
|
+
// Custom HTML tag — "drop it anywhere" behavior
|
|
3218
|
+
// ===========================================================================
|
|
3219
|
+
|
|
3220
|
+
describe('custom HTML tag — drop-in component mounting', () => {
|
|
3221
|
+
|
|
3222
|
+
// -- Basic rendering -------------------------------------------------------
|
|
3223
|
+
|
|
3224
|
+
it('renders a component when its custom tag appears in the DOM', () => {
|
|
3225
|
+
component('drop-basic', {
|
|
3226
|
+
render() { return '<p class="drop-basic-out">Hello from drop-in</p>'; },
|
|
3227
|
+
});
|
|
3228
|
+
document.body.innerHTML = '<drop-basic></drop-basic>';
|
|
3229
|
+
mountAll();
|
|
3230
|
+
expect(document.querySelector('.drop-basic-out').textContent).toBe('Hello from drop-in');
|
|
3231
|
+
});
|
|
3232
|
+
|
|
3233
|
+
it('renders initial state into the template', () => {
|
|
3234
|
+
component('drop-state', {
|
|
3235
|
+
state: () => ({ name: 'World' }),
|
|
3236
|
+
render() { return `<span class="drop-state-out">Hello ${this.state.name}</span>`; },
|
|
3237
|
+
});
|
|
3238
|
+
document.body.innerHTML = '<drop-state></drop-state>';
|
|
3239
|
+
mountAll();
|
|
3240
|
+
expect(document.querySelector('.drop-state-out').textContent).toBe('Hello World');
|
|
3241
|
+
});
|
|
3242
|
+
|
|
3243
|
+
// -- State reactivity ------------------------------------------------------
|
|
3244
|
+
|
|
3245
|
+
it('re-renders when state changes', async () => {
|
|
3246
|
+
component('drop-reactive', {
|
|
3247
|
+
state: () => ({ count: 0 }),
|
|
3248
|
+
render() { return `<span class="drop-r">${this.state.count}</span>`; },
|
|
3249
|
+
});
|
|
3250
|
+
document.body.innerHTML = '<drop-reactive></drop-reactive>';
|
|
3251
|
+
mountAll();
|
|
3252
|
+
expect(document.querySelector('.drop-r').textContent).toBe('0');
|
|
3253
|
+
|
|
3254
|
+
const inst = getInstance(document.querySelector('drop-reactive'));
|
|
3255
|
+
inst.state.count = 5;
|
|
3256
|
+
await new Promise(r => setTimeout(r, 50));
|
|
3257
|
+
expect(document.querySelector('.drop-r').textContent).toBe('5');
|
|
3258
|
+
});
|
|
3259
|
+
|
|
3260
|
+
// -- Event handling --------------------------------------------------------
|
|
3261
|
+
|
|
3262
|
+
it('handles @click on a button inside the component', async () => {
|
|
3263
|
+
component('drop-click', {
|
|
3264
|
+
state: () => ({ count: 0 }),
|
|
3265
|
+
increment() { this.state.count++; },
|
|
3266
|
+
render() {
|
|
3267
|
+
return `
|
|
3268
|
+
<div>
|
|
3269
|
+
<span class="drop-click-val">${this.state.count}</span>
|
|
3270
|
+
<button class="drop-click-btn" @click="increment">+1</button>
|
|
3271
|
+
</div>
|
|
3272
|
+
`;
|
|
3273
|
+
},
|
|
3274
|
+
});
|
|
3275
|
+
document.body.innerHTML = '<drop-click></drop-click>';
|
|
3276
|
+
mountAll();
|
|
3277
|
+
expect(document.querySelector('.drop-click-val').textContent).toBe('0');
|
|
3278
|
+
|
|
3279
|
+
document.querySelector('.drop-click-btn').click();
|
|
3280
|
+
await new Promise(r => setTimeout(r, 50));
|
|
3281
|
+
expect(document.querySelector('.drop-click-val').textContent).toBe('1');
|
|
3282
|
+
|
|
3283
|
+
document.querySelector('.drop-click-btn').click();
|
|
3284
|
+
document.querySelector('.drop-click-btn').click();
|
|
3285
|
+
await new Promise(r => setTimeout(r, 50));
|
|
3286
|
+
expect(document.querySelector('.drop-click-val').textContent).toBe('3');
|
|
3287
|
+
});
|
|
3288
|
+
|
|
3289
|
+
// -- Multiple instances (independent state) --------------------------------
|
|
3290
|
+
|
|
3291
|
+
it('mounts multiple instances with independent state', async () => {
|
|
3292
|
+
component('drop-multi', {
|
|
3293
|
+
state: () => ({ n: 0 }),
|
|
3294
|
+
bump() { this.state.n++; },
|
|
3295
|
+
render() {
|
|
3296
|
+
return `<div><span class="dm-val">${this.state.n}</span><button class="dm-btn" @click="bump">+</button></div>`;
|
|
3297
|
+
},
|
|
3298
|
+
});
|
|
3299
|
+
document.body.innerHTML = `
|
|
3300
|
+
<drop-multi id="dm1"></drop-multi>
|
|
3301
|
+
<drop-multi id="dm2"></drop-multi>
|
|
3302
|
+
`;
|
|
3303
|
+
mountAll();
|
|
3304
|
+
|
|
3305
|
+
const vals = () => [...document.querySelectorAll('.dm-val')].map(el => el.textContent);
|
|
3306
|
+
expect(vals()).toEqual(['0', '0']);
|
|
3307
|
+
|
|
3308
|
+
// Click only the first one's button
|
|
3309
|
+
document.querySelector('#dm1 .dm-btn').click();
|
|
3310
|
+
await new Promise(r => setTimeout(r, 50));
|
|
3311
|
+
expect(vals()).toEqual(['1', '0']);
|
|
3312
|
+
});
|
|
3313
|
+
|
|
3314
|
+
// -- Static props from attributes ------------------------------------------
|
|
3315
|
+
|
|
3316
|
+
it('passes static attributes as props', () => {
|
|
3317
|
+
component('drop-props', {
|
|
3318
|
+
render() { return `<span class="dp-out">${this.props.label}</span>`; },
|
|
3319
|
+
});
|
|
3320
|
+
document.body.innerHTML = '<drop-props label="Click me"></drop-props>';
|
|
3321
|
+
mountAll();
|
|
3322
|
+
expect(document.querySelector('.dp-out').textContent).toBe('Click me');
|
|
3323
|
+
});
|
|
3324
|
+
|
|
3325
|
+
it('JSON-parses numeric and boolean attribute values', () => {
|
|
3326
|
+
component('drop-json-props', {
|
|
3327
|
+
render() {
|
|
3328
|
+
return `<span class="djp-type">${typeof this.props.count}-${typeof this.props.active}</span>`;
|
|
3329
|
+
},
|
|
3330
|
+
});
|
|
3331
|
+
document.body.innerHTML = '<drop-json-props count="5" active="true"></drop-json-props>';
|
|
3332
|
+
mountAll();
|
|
3333
|
+
expect(document.querySelector('.djp-type').textContent).toBe('number-boolean');
|
|
3334
|
+
});
|
|
3335
|
+
|
|
3336
|
+
// -- Lifecycle hooks -------------------------------------------------------
|
|
3337
|
+
|
|
3338
|
+
it('calls init and mounted hooks', () => {
|
|
3339
|
+
const order = [];
|
|
3340
|
+
component('drop-lifecycle', {
|
|
3341
|
+
init() { order.push('init'); },
|
|
3342
|
+
mounted() { order.push('mounted'); },
|
|
3343
|
+
render() { return '<div>lifecycle</div>'; },
|
|
3344
|
+
});
|
|
3345
|
+
document.body.innerHTML = '<drop-lifecycle></drop-lifecycle>';
|
|
3346
|
+
mountAll();
|
|
3347
|
+
expect(order).toEqual(['init', 'mounted']);
|
|
3348
|
+
});
|
|
3349
|
+
|
|
3350
|
+
it('calls destroyed hook when instance is destroyed', () => {
|
|
3351
|
+
let destroyed = false;
|
|
3352
|
+
component('drop-destroy', {
|
|
3353
|
+
destroyed() { destroyed = true; },
|
|
3354
|
+
render() { return '<div>destroyable</div>'; },
|
|
3355
|
+
});
|
|
3356
|
+
document.body.innerHTML = '<drop-destroy></drop-destroy>';
|
|
3357
|
+
mountAll();
|
|
3358
|
+
const inst = getInstance(document.querySelector('drop-destroy'));
|
|
3359
|
+
inst.destroy();
|
|
3360
|
+
expect(destroyed).toBe(true);
|
|
3361
|
+
});
|
|
3362
|
+
|
|
3363
|
+
// -- Nested components (child tags inside parent render) -------------------
|
|
3364
|
+
|
|
3365
|
+
it('auto-mounts child components rendered inside a parent', () => {
|
|
3366
|
+
component('drop-child', {
|
|
3367
|
+
render() { return '<em class="dc-child">child</em>'; },
|
|
3368
|
+
});
|
|
3369
|
+
component('drop-parent', {
|
|
3370
|
+
render() { return '<div class="dc-parent"><drop-child></drop-child></div>'; },
|
|
3371
|
+
});
|
|
3372
|
+
document.body.innerHTML = '<drop-parent></drop-parent>';
|
|
3373
|
+
mountAll();
|
|
3374
|
+
expect(document.querySelector('.dc-parent')).not.toBeNull();
|
|
3375
|
+
expect(document.querySelector('.dc-child').textContent).toBe('child');
|
|
3376
|
+
});
|
|
3377
|
+
|
|
3378
|
+
// -- Idempotency -----------------------------------------------------------
|
|
3379
|
+
|
|
3380
|
+
it('does not re-mount an already-mounted tag on repeated mountAll calls', () => {
|
|
3381
|
+
let initCount = 0;
|
|
3382
|
+
component('drop-idempotent', {
|
|
3383
|
+
init() { initCount++; },
|
|
3384
|
+
render() { return '<div>idem</div>'; },
|
|
3385
|
+
});
|
|
3386
|
+
document.body.innerHTML = '<drop-idempotent></drop-idempotent>';
|
|
3387
|
+
mountAll();
|
|
3388
|
+
mountAll();
|
|
3389
|
+
mountAll();
|
|
3390
|
+
expect(initCount).toBe(1);
|
|
3391
|
+
});
|
|
3392
|
+
|
|
3393
|
+
// -- Scattered placement ---------------------------------------------------
|
|
3394
|
+
|
|
3395
|
+
it('mounts tags scattered among regular HTML', () => {
|
|
3396
|
+
component('drop-scattered', {
|
|
3397
|
+
render() { return '<b class="ds-out">found</b>'; },
|
|
3398
|
+
});
|
|
3399
|
+
document.body.innerHTML = `
|
|
3400
|
+
<header><h1>Page Title</h1></header>
|
|
3401
|
+
<main>
|
|
3402
|
+
<p>Some content</p>
|
|
3403
|
+
<drop-scattered></drop-scattered>
|
|
3404
|
+
<p>More content</p>
|
|
3405
|
+
</main>
|
|
3406
|
+
<footer>
|
|
3407
|
+
<drop-scattered></drop-scattered>
|
|
3408
|
+
</footer>
|
|
3409
|
+
`;
|
|
3410
|
+
mountAll();
|
|
3411
|
+
const tags = document.querySelectorAll('.ds-out');
|
|
3412
|
+
expect(tags.length).toBe(2);
|
|
3413
|
+
tags.forEach(el => expect(el.textContent).toBe('found'));
|
|
3414
|
+
});
|
|
3415
|
+
|
|
3416
|
+
// -- Full click-counter example from docs ----------------------------------
|
|
3417
|
+
|
|
3418
|
+
it('click-counter from docs works end-to-end', async () => {
|
|
3419
|
+
component('click-counter', {
|
|
3420
|
+
state: () => ({ count: 0 }),
|
|
3421
|
+
increment() { this.state.count++; },
|
|
3422
|
+
render() {
|
|
3423
|
+
return `
|
|
3424
|
+
<div class="counter">
|
|
3425
|
+
<span class="cc-count">Count: ${this.state.count}</span>
|
|
3426
|
+
<button class="cc-btn" @click="increment">+1</button>
|
|
3427
|
+
</div>
|
|
3428
|
+
`;
|
|
3429
|
+
},
|
|
3430
|
+
});
|
|
3431
|
+
|
|
3432
|
+
// Drop it anywhere — just like the docs say
|
|
3433
|
+
document.body.innerHTML = `
|
|
3434
|
+
<h1>My Page</h1>
|
|
3435
|
+
<click-counter></click-counter>
|
|
3436
|
+
<p>Some other content</p>
|
|
3437
|
+
<click-counter></click-counter>
|
|
3438
|
+
`;
|
|
3439
|
+
mountAll();
|
|
3440
|
+
|
|
3441
|
+
const counts = () => [...document.querySelectorAll('.cc-count')].map(el => el.textContent);
|
|
3442
|
+
const buttons = document.querySelectorAll('.cc-btn');
|
|
3443
|
+
|
|
3444
|
+
expect(counts()).toEqual(['Count: 0', 'Count: 0']);
|
|
3445
|
+
|
|
3446
|
+
// Click the first counter 3 times
|
|
3447
|
+
buttons[0].click();
|
|
3448
|
+
buttons[0].click();
|
|
3449
|
+
buttons[0].click();
|
|
3450
|
+
await new Promise(r => setTimeout(r, 50));
|
|
3451
|
+
expect(counts()).toEqual(['Count: 3', 'Count: 0']);
|
|
3452
|
+
|
|
3453
|
+
// Click the second counter once
|
|
3454
|
+
buttons[1].click();
|
|
3455
|
+
await new Promise(r => setTimeout(r, 50));
|
|
3456
|
+
expect(counts()).toEqual(['Count: 3', 'Count: 1']);
|
|
3457
|
+
});
|
|
3458
|
+
});
|