p-elements-core 1.2.32-rc8 → 1.2.32
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/.editorconfig +17 -17
- package/.gitlab-ci.yml +18 -18
- package/CHANGELOG.md +201 -201
- package/demo/sample.js +1 -1
- package/demo/screen.css +16 -16
- package/dist/p-elements-core-modern.js +1 -1
- package/dist/p-elements-core.js +1 -1
- package/docs/package-lock.json +6897 -6897
- package/docs/package.json +27 -27
- package/docs/src/404.md +8 -8
- package/docs/src/_data/demos/hello-world/hello-world.tsx +35 -35
- package/docs/src/_data/demos/hello-world/index.html +10 -10
- package/docs/src/_data/demos/hello-world/project.json +7 -7
- package/docs/src/_data/demos/timer/demo-timer.tsx +120 -120
- package/docs/src/_data/demos/timer/icons.tsx +62 -62
- package/docs/src/_data/demos/timer/index.html +12 -12
- package/docs/src/_data/demos/timer/project.json +8 -8
- package/docs/src/_data/global.js +13 -13
- package/docs/src/_data/helpers.js +19 -19
- package/docs/src/_includes/layouts/base.njk +30 -30
- package/docs/src/_includes/layouts/playground.njk +40 -40
- package/docs/src/_includes/partials/app-header.njk +8 -8
- package/docs/src/_includes/partials/head.njk +14 -14
- package/docs/src/_includes/partials/nav.njk +19 -19
- package/docs/src/_includes/partials/top-nav.njk +51 -51
- package/docs/src/documentation/custom-element.md +221 -221
- package/docs/src/documentation/decorators/bind.md +71 -71
- package/docs/src/documentation/decorators/custom-element-config.md +63 -63
- package/docs/src/documentation/decorators/property.md +83 -83
- package/docs/src/documentation/decorators/query.md +66 -66
- package/docs/src/documentation/decorators/render-property-on-set.md +60 -60
- package/docs/src/documentation/decorators.md +9 -9
- package/docs/src/documentation/reactive-properties.md +53 -53
- package/docs/src/index.d.ts +25 -25
- package/docs/src/index.md +3 -3
- package/docs/src/scripts/components/app-mode-switch/app-mode-switch.css +78 -78
- package/docs/src/scripts/components/app-mode-switch/app-mode-switch.tsx +166 -166
- package/docs/src/scripts/components/app-playground/app-playground.tsx +189 -189
- package/docs/tsconfig.json +22 -22
- package/index.html +10 -2
- package/package.json +1 -1
- package/readme.md +206 -206
- package/src/custom-element-controller.ts +31 -31
- package/src/custom-element.test.ts +906 -906
- package/src/custom-element.ts +3 -8
- package/src/decorators/bind.test.ts +163 -163
- package/src/decorators/bind.ts +46 -46
- package/src/decorators/custom-element-config.ts +17 -17
- package/src/decorators/property.test.ts +279 -279
- package/src/decorators/query.test.ts +146 -146
- package/src/decorators/query.ts +12 -12
- package/src/decorators/render-property-on-set.ts +3 -3
- package/src/helpers/css.ts +71 -71
- package/src/maquette/cache.ts +35 -35
- package/src/maquette/dom.ts +115 -115
- package/src/maquette/h.ts +100 -100
- package/src/maquette/index.ts +12 -12
- package/src/maquette/interfaces.ts +536 -536
- package/src/maquette/jsx.ts +61 -61
- package/src/maquette/mapping.ts +56 -56
- package/src/maquette/projection.ts +666 -666
- package/src/maquette/projector.ts +205 -205
- package/src/sample/mixin/highlight.tsx +33 -33
- package/src/sample/sample.tsx +98 -0
|
@@ -1,906 +1,906 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for CustomElement base class
|
|
3
|
-
* Covers lifecycle, rendering, and update coordination
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { describe, it, expect } from 'vitest';
|
|
7
|
-
import './test-setup.js';
|
|
8
|
-
import { CustomElement } from './custom-element.js';
|
|
9
|
-
import { customElementConfig } from './decorators/custom-element-config.js';
|
|
10
|
-
import { property } from './decorators/property.js';
|
|
11
|
-
import { generateUniqueTagName } from './test-setup.js';
|
|
12
|
-
import { waitForRender } from './test-utils.js';
|
|
13
|
-
|
|
14
|
-
describe('CustomElement', () => {
|
|
15
|
-
describe('Lifecycle', () => {
|
|
16
|
-
it('should create shadow root on connection', async () => {
|
|
17
|
-
const tagName = generateUniqueTagName('lifecycle-test');
|
|
18
|
-
|
|
19
|
-
@customElementConfig({ tagName })
|
|
20
|
-
class LifecycleTest extends CustomElement {
|
|
21
|
-
static style = ':host { display: block; }';
|
|
22
|
-
|
|
23
|
-
render() {
|
|
24
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const el = document.createElement(tagName) as LifecycleTest;
|
|
29
|
-
// Shadow root is created in constructor when static style is defined
|
|
30
|
-
expect(el.shadowRoot).toBeDefined();
|
|
31
|
-
|
|
32
|
-
document.body.appendChild(el);
|
|
33
|
-
await waitForRender(el);
|
|
34
|
-
|
|
35
|
-
expect(el.shadowRoot).toBeDefined();
|
|
36
|
-
|
|
37
|
-
document.body.removeChild(el);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('should call init() once', async () => {
|
|
41
|
-
const tagName = generateUniqueTagName('lifecycle-test');
|
|
42
|
-
let initCount = 0;
|
|
43
|
-
|
|
44
|
-
@customElementConfig({ tagName })
|
|
45
|
-
class LifecycleTest extends CustomElement {
|
|
46
|
-
static style = ':host { display: block; }';
|
|
47
|
-
|
|
48
|
-
init() {
|
|
49
|
-
initCount++;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
render() {
|
|
53
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const el = document.createElement(tagName) as LifecycleTest;
|
|
58
|
-
document.body.appendChild(el);
|
|
59
|
-
await waitForRender(el);
|
|
60
|
-
|
|
61
|
-
// Remove and re-add
|
|
62
|
-
document.body.removeChild(el);
|
|
63
|
-
document.body.appendChild(el);
|
|
64
|
-
await waitForRender(el);
|
|
65
|
-
|
|
66
|
-
expect(initCount).toBe(1);
|
|
67
|
-
|
|
68
|
-
document.body.removeChild(el);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('should resolve updateComplete promise', async () => {
|
|
72
|
-
const tagName = generateUniqueTagName('lifecycle-test');
|
|
73
|
-
|
|
74
|
-
@customElementConfig({ tagName })
|
|
75
|
-
class LifecycleTest extends CustomElement {
|
|
76
|
-
static style = ':host { display: block; }';
|
|
77
|
-
|
|
78
|
-
@property({ type: String })
|
|
79
|
-
value = '';
|
|
80
|
-
|
|
81
|
-
render() {
|
|
82
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const el = document.createElement(tagName) as LifecycleTest;
|
|
87
|
-
document.body.appendChild(el);
|
|
88
|
-
|
|
89
|
-
el.value = 'test';
|
|
90
|
-
const promise = el.updateComplete;
|
|
91
|
-
|
|
92
|
-
expect(promise).toBeInstanceOf(Promise);
|
|
93
|
-
await promise;
|
|
94
|
-
|
|
95
|
-
expect(el.value).toBe('test');
|
|
96
|
-
|
|
97
|
-
document.body.removeChild(el);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe('Rendering', () => {
|
|
102
|
-
it('should render content to shadow DOM', async () => {
|
|
103
|
-
const tagName = generateUniqueTagName('render-test');
|
|
104
|
-
|
|
105
|
-
@customElementConfig({ tagName })
|
|
106
|
-
class RenderTest extends CustomElement {
|
|
107
|
-
static style = ':host { display: block; }';
|
|
108
|
-
|
|
109
|
-
render() {
|
|
110
|
-
return {
|
|
111
|
-
vnodeSelector: 'div',
|
|
112
|
-
properties: { class: 'content' }, text: undefined, domNode: null,
|
|
113
|
-
children: [
|
|
114
|
-
{ vnodeSelector: 'span', properties: {}, text: 'Hello World', domNode: null, children: undefined },
|
|
115
|
-
],
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const el = document.createElement(tagName) as RenderTest;
|
|
121
|
-
document.body.appendChild(el);
|
|
122
|
-
await waitForRender(el);
|
|
123
|
-
|
|
124
|
-
const div = el.shadowRoot?.querySelector('div');
|
|
125
|
-
expect(div).toBeDefined();
|
|
126
|
-
expect(div?.className).toBe('content');
|
|
127
|
-
|
|
128
|
-
const span = el.shadowRoot?.querySelector('span');
|
|
129
|
-
expect(span).toBeDefined();
|
|
130
|
-
expect(span?.textContent).toBe('Hello World');
|
|
131
|
-
|
|
132
|
-
document.body.removeChild(el);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('should re-render when renderNow() is called', async () => {
|
|
136
|
-
const tagName = generateUniqueTagName('render-test');
|
|
137
|
-
let renderCount = 0;
|
|
138
|
-
|
|
139
|
-
@customElementConfig({ tagName })
|
|
140
|
-
class RenderTest extends CustomElement {
|
|
141
|
-
static style = ':host { display: block; }';
|
|
142
|
-
|
|
143
|
-
render() {
|
|
144
|
-
renderCount++;
|
|
145
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const el = document.createElement(tagName) as RenderTest;
|
|
150
|
-
document.body.appendChild(el);
|
|
151
|
-
await waitForRender(el);
|
|
152
|
-
|
|
153
|
-
const initialCount = renderCount;
|
|
154
|
-
el.renderNow();
|
|
155
|
-
|
|
156
|
-
expect(renderCount).toBeGreaterThan(initialCount);
|
|
157
|
-
|
|
158
|
-
document.body.removeChild(el);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('should inject static style into shadow DOM', async () => {
|
|
162
|
-
const tagName = generateUniqueTagName('style-test');
|
|
163
|
-
|
|
164
|
-
@customElementConfig({ tagName })
|
|
165
|
-
class StyleTest extends CustomElement {
|
|
166
|
-
static style = 'div { color: red; }';
|
|
167
|
-
|
|
168
|
-
render() {
|
|
169
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const el = document.createElement(tagName) as StyleTest;
|
|
174
|
-
document.body.appendChild(el);
|
|
175
|
-
await waitForRender(el);
|
|
176
|
-
|
|
177
|
-
// Check that shadow root exists and has content
|
|
178
|
-
expect(el.shadowRoot).toBeDefined();
|
|
179
|
-
const div = el.shadowRoot?.querySelector('div');
|
|
180
|
-
expect(div).toBeDefined();
|
|
181
|
-
|
|
182
|
-
document.body.removeChild(el);
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
describe('Update coordination', () => {
|
|
187
|
-
it('should call updated() after property changes', async () => {
|
|
188
|
-
const tagName = generateUniqueTagName('update-test');
|
|
189
|
-
const updates: string[] = [];
|
|
190
|
-
|
|
191
|
-
@customElementConfig({ tagName })
|
|
192
|
-
class UpdateTest extends CustomElement {
|
|
193
|
-
static style = ':host { display: block; }';
|
|
194
|
-
|
|
195
|
-
@property({ type: String })
|
|
196
|
-
value = '';
|
|
197
|
-
|
|
198
|
-
updated(propertyKey: string) {
|
|
199
|
-
updates.push(propertyKey);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
render() {
|
|
203
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const el = document.createElement(tagName) as UpdateTest;
|
|
208
|
-
document.body.appendChild(el);
|
|
209
|
-
|
|
210
|
-
el.value = 'changed';
|
|
211
|
-
await waitForRender(el);
|
|
212
|
-
|
|
213
|
-
expect(updates).toContain('value');
|
|
214
|
-
|
|
215
|
-
document.body.removeChild(el);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('should block update when shouldUpdate() returns false', async () => {
|
|
219
|
-
const tagName = generateUniqueTagName('update-test');
|
|
220
|
-
|
|
221
|
-
@customElementConfig({ tagName })
|
|
222
|
-
class UpdateTest extends CustomElement {
|
|
223
|
-
static style = ':host { display: block; }';
|
|
224
|
-
|
|
225
|
-
@property({ type: Number })
|
|
226
|
-
count = 0;
|
|
227
|
-
|
|
228
|
-
shouldUpdate(propertyKey: string, oldValue: any, newValue: any): boolean {
|
|
229
|
-
return newValue !== 5; // Block updates to 5
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
render() {
|
|
233
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const el = document.createElement(tagName) as UpdateTest;
|
|
238
|
-
document.body.appendChild(el);
|
|
239
|
-
|
|
240
|
-
el.count = 5;
|
|
241
|
-
await waitForRender(el);
|
|
242
|
-
expect(el.count).toBe(0); // Should not update
|
|
243
|
-
|
|
244
|
-
el.count = 10;
|
|
245
|
-
await waitForRender(el);
|
|
246
|
-
expect(el.count).toBe(10); // Should update
|
|
247
|
-
|
|
248
|
-
document.body.removeChild(el);
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
describe('Form association', () => {
|
|
253
|
-
it('should support form-associated custom elements', async () => {
|
|
254
|
-
const tagName = generateUniqueTagName('form-test');
|
|
255
|
-
|
|
256
|
-
@customElementConfig({ tagName })
|
|
257
|
-
class FormTest extends CustomElement {
|
|
258
|
-
static formAssociated = true;
|
|
259
|
-
static style = ':host { display: inline-block; }';
|
|
260
|
-
|
|
261
|
-
@property({ type: String })
|
|
262
|
-
value = '';
|
|
263
|
-
|
|
264
|
-
render() {
|
|
265
|
-
return { vnodeSelector: 'input', properties: {}, children: [], text: undefined, domNode: null };
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const form = document.createElement('form');
|
|
270
|
-
const el = document.createElement(tagName) as FormTest;
|
|
271
|
-
form.appendChild(el);
|
|
272
|
-
document.body.appendChild(form);
|
|
273
|
-
await waitForRender(el);
|
|
274
|
-
|
|
275
|
-
expect(el.internals).toBeDefined();
|
|
276
|
-
|
|
277
|
-
document.body.removeChild(form);
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
describe('Attribute changes', () => {
|
|
282
|
-
it('should sync attribute changes to properties', async () => {
|
|
283
|
-
const tagName = generateUniqueTagName('attr-test');
|
|
284
|
-
|
|
285
|
-
@customElementConfig({ tagName })
|
|
286
|
-
class AttrTest extends CustomElement {
|
|
287
|
-
static style = ':host { display: block; }';
|
|
288
|
-
|
|
289
|
-
@property({ type: String })
|
|
290
|
-
message = '';
|
|
291
|
-
|
|
292
|
-
render() {
|
|
293
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const el = document.createElement(tagName) as AttrTest;
|
|
298
|
-
document.body.appendChild(el);
|
|
299
|
-
await waitForRender(el);
|
|
300
|
-
|
|
301
|
-
el.setAttribute('message', 'hello');
|
|
302
|
-
await new Promise(resolve => setTimeout(resolve, 10));
|
|
303
|
-
expect(el.message).toBe('hello');
|
|
304
|
-
|
|
305
|
-
document.body.removeChild(el);
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it('should handle boolean attributes', async () => {
|
|
309
|
-
const tagName = generateUniqueTagName('attr-test');
|
|
310
|
-
|
|
311
|
-
@customElementConfig({ tagName })
|
|
312
|
-
class AttrTest extends CustomElement {
|
|
313
|
-
static style = ':host { display: block; }';
|
|
314
|
-
|
|
315
|
-
@property({ type: Boolean })
|
|
316
|
-
enabled = false;
|
|
317
|
-
|
|
318
|
-
render() {
|
|
319
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const el = document.createElement(tagName) as AttrTest;
|
|
324
|
-
document.body.appendChild(el);
|
|
325
|
-
await waitForRender(el);
|
|
326
|
-
|
|
327
|
-
el.setAttribute('enabled', '');
|
|
328
|
-
await new Promise(resolve => setTimeout(resolve, 10));
|
|
329
|
-
expect(el.enabled).toBe(true);
|
|
330
|
-
|
|
331
|
-
el.removeAttribute('enabled');
|
|
332
|
-
await new Promise(resolve => setTimeout(resolve, 10));
|
|
333
|
-
expect(el.enabled).toBe(false);
|
|
334
|
-
|
|
335
|
-
document.body.removeChild(el);
|
|
336
|
-
});
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
describe('Projector modes', () => {
|
|
340
|
-
it('should support different projector modes', async () => {
|
|
341
|
-
const tagName = generateUniqueTagName('projector-test');
|
|
342
|
-
|
|
343
|
-
@customElementConfig({ tagName })
|
|
344
|
-
class ProjectorTest extends CustomElement {
|
|
345
|
-
static style = ':host { display: block; }';
|
|
346
|
-
static projectorMode = 'merge';
|
|
347
|
-
|
|
348
|
-
render() {
|
|
349
|
-
return { vnodeSelector: 'div', properties: {}, text: undefined, domNode: null, children: [
|
|
350
|
-
{ vnodeSelector: 'span', properties: {}, children: [], text: 'test', domNode: null }
|
|
351
|
-
] };
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const el = document.createElement(tagName) as ProjectorTest;
|
|
356
|
-
document.body.appendChild(el);
|
|
357
|
-
await waitForRender(el);
|
|
358
|
-
|
|
359
|
-
expect(el.shadowRoot).toBeDefined();
|
|
360
|
-
expect(el.shadowRoot.querySelector('span')).toBeDefined();
|
|
361
|
-
|
|
362
|
-
document.body.removeChild(el);
|
|
363
|
-
});
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
describe('Property upgrades', () => {
|
|
367
|
-
it('should upgrade properties set before connection', async () => {
|
|
368
|
-
const tagName = generateUniqueTagName('upgrade-test');
|
|
369
|
-
|
|
370
|
-
@customElementConfig({ tagName })
|
|
371
|
-
class UpgradeTest extends CustomElement {
|
|
372
|
-
static style = ':host { display: block; }';
|
|
373
|
-
|
|
374
|
-
@property({ type: String })
|
|
375
|
-
presetValue = '';
|
|
376
|
-
|
|
377
|
-
render() {
|
|
378
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const el = document.createElement(tagName) as UpgradeTest;
|
|
383
|
-
|
|
384
|
-
// Set property before connecting to DOM
|
|
385
|
-
(el as any).presetValue = 'preset';
|
|
386
|
-
|
|
387
|
-
document.body.appendChild(el);
|
|
388
|
-
await waitForRender(el);
|
|
389
|
-
|
|
390
|
-
expect(el.presetValue).toBe('preset');
|
|
391
|
-
|
|
392
|
-
document.body.removeChild(el);
|
|
393
|
-
});
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
describe('Controllers', () => {
|
|
397
|
-
it('should invoke controller lifecycle methods', async () => {
|
|
398
|
-
const tagName = generateUniqueTagName('controller-test');
|
|
399
|
-
const lifecycle: string[] = [];
|
|
400
|
-
|
|
401
|
-
const controller = {
|
|
402
|
-
connected: () => lifecycle.push('connected'),
|
|
403
|
-
disconnected: () => lifecycle.push('disconnected'),
|
|
404
|
-
hostRenderStart: () => lifecycle.push('renderStart'),
|
|
405
|
-
hostRenderDone: () => lifecycle.push('renderDone'),
|
|
406
|
-
renderNow: () => {},
|
|
407
|
-
hostElement: null as any
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
@customElementConfig({ tagName })
|
|
411
|
-
class ControllerTest extends CustomElement {
|
|
412
|
-
static style = ':host { display: block; }';
|
|
413
|
-
|
|
414
|
-
constructor() {
|
|
415
|
-
super();
|
|
416
|
-
this.addController(controller);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
render() {
|
|
420
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
const el = document.createElement(tagName) as ControllerTest;
|
|
425
|
-
document.body.appendChild(el);
|
|
426
|
-
await waitForRender(el);
|
|
427
|
-
|
|
428
|
-
expect(lifecycle).toContain('connected');
|
|
429
|
-
expect(lifecycle).toContain('renderStart');
|
|
430
|
-
expect(lifecycle).toContain('renderDone');
|
|
431
|
-
|
|
432
|
-
document.body.removeChild(el);
|
|
433
|
-
expect(lifecycle).toContain('disconnected');
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
it('should call controller.connected when added after element is connected', async () => {
|
|
437
|
-
const tagName = generateUniqueTagName('controller-test');
|
|
438
|
-
let controllerConnected = false;
|
|
439
|
-
|
|
440
|
-
@customElementConfig({ tagName })
|
|
441
|
-
class ControllerTest extends CustomElement {
|
|
442
|
-
static style = ':host { display: block; }';
|
|
443
|
-
|
|
444
|
-
render() {
|
|
445
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const el = document.createElement(tagName) as ControllerTest;
|
|
450
|
-
document.body.appendChild(el);
|
|
451
|
-
await waitForRender(el);
|
|
452
|
-
|
|
453
|
-
// Add controller after connection
|
|
454
|
-
el.addController({
|
|
455
|
-
connected: () => { controllerConnected = true; },
|
|
456
|
-
renderNow: () => {},
|
|
457
|
-
hostElement: null as any
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
expect(controllerConnected).toBe(true);
|
|
461
|
-
document.body.removeChild(el);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
it('should handle multiple controllers', async () => {
|
|
465
|
-
const tagName = generateUniqueTagName('controller-test');
|
|
466
|
-
const calls: string[] = [];
|
|
467
|
-
|
|
468
|
-
@customElementConfig({ tagName })
|
|
469
|
-
class ControllerTest extends CustomElement {
|
|
470
|
-
static style = ':host { display: block; }';
|
|
471
|
-
|
|
472
|
-
constructor() {
|
|
473
|
-
super();
|
|
474
|
-
this.addController({
|
|
475
|
-
connected: () => calls.push('controller1-connected'),
|
|
476
|
-
renderNow: () => {},
|
|
477
|
-
hostElement: null as any
|
|
478
|
-
});
|
|
479
|
-
this.addController({
|
|
480
|
-
connected: () => calls.push('controller2-connected'),
|
|
481
|
-
renderNow: () => {},
|
|
482
|
-
hostElement: null as any
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
render() {
|
|
487
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
const el = document.createElement(tagName) as ControllerTest;
|
|
492
|
-
document.body.appendChild(el);
|
|
493
|
-
await waitForRender(el);
|
|
494
|
-
|
|
495
|
-
expect(calls).toContain('controller1-connected');
|
|
496
|
-
expect(calls).toContain('controller2-connected');
|
|
497
|
-
|
|
498
|
-
document.body.removeChild(el);
|
|
499
|
-
});
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
describe('Render lifecycle hooks', () => {
|
|
503
|
-
it('should call renderStart and renderDone hooks', async () => {
|
|
504
|
-
const tagName = generateUniqueTagName('lifecycle-test');
|
|
505
|
-
const events: string[] = [];
|
|
506
|
-
|
|
507
|
-
@customElementConfig({ tagName })
|
|
508
|
-
class LifecycleTest extends CustomElement {
|
|
509
|
-
static style = ':host { display: block; }';
|
|
510
|
-
|
|
511
|
-
renderStart = (isFirst: boolean) => {
|
|
512
|
-
events.push(`renderStart-${isFirst}`);
|
|
513
|
-
};
|
|
514
|
-
|
|
515
|
-
renderDone = (isFirst: boolean) => {
|
|
516
|
-
events.push(`renderDone-${isFirst}`);
|
|
517
|
-
};
|
|
518
|
-
|
|
519
|
-
render() {
|
|
520
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
const el = document.createElement(tagName) as LifecycleTest;
|
|
525
|
-
document.body.appendChild(el);
|
|
526
|
-
await waitForRender(el);
|
|
527
|
-
|
|
528
|
-
expect(events).toContain('renderStart-true');
|
|
529
|
-
expect(events).toContain('renderDone-true');
|
|
530
|
-
|
|
531
|
-
// Second render should have isFirst=false
|
|
532
|
-
el.renderNow();
|
|
533
|
-
await waitForRender(el);
|
|
534
|
-
|
|
535
|
-
expect(events).toContain('renderStart-false');
|
|
536
|
-
expect(events).toContain('renderDone-false');
|
|
537
|
-
|
|
538
|
-
document.body.removeChild(el);
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
it('should dispatch firstRender event', async () => {
|
|
542
|
-
const tagName = generateUniqueTagName('lifecycle-test');
|
|
543
|
-
let firstRenderDispatched = false;
|
|
544
|
-
|
|
545
|
-
@customElementConfig({ tagName })
|
|
546
|
-
class LifecycleTest extends CustomElement {
|
|
547
|
-
static style = ':host { display: block; }';
|
|
548
|
-
|
|
549
|
-
render() {
|
|
550
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
const el = document.createElement(tagName) as LifecycleTest;
|
|
555
|
-
el.addEventListener('firstRender', () => {
|
|
556
|
-
firstRenderDispatched = true;
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
document.body.appendChild(el);
|
|
560
|
-
await waitForRender(el);
|
|
561
|
-
|
|
562
|
-
expect(firstRenderDispatched).toBe(true);
|
|
563
|
-
document.body.removeChild(el);
|
|
564
|
-
});
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
describe('requestUpdate and updateComplete', () => {
|
|
568
|
-
it('should return promise for requestUpdate', async () => {
|
|
569
|
-
const tagName = generateUniqueTagName('update-test');
|
|
570
|
-
|
|
571
|
-
@customElementConfig({ tagName })
|
|
572
|
-
class UpdateTest extends CustomElement {
|
|
573
|
-
static style = ':host { display: block; }';
|
|
574
|
-
|
|
575
|
-
render() {
|
|
576
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
const el = document.createElement(tagName) as UpdateTest;
|
|
581
|
-
document.body.appendChild(el);
|
|
582
|
-
await waitForRender(el);
|
|
583
|
-
|
|
584
|
-
const promise = el.requestUpdate();
|
|
585
|
-
expect(promise).toBeInstanceOf(Promise);
|
|
586
|
-
|
|
587
|
-
// Wait for the update
|
|
588
|
-
await Promise.race([promise, new Promise(resolve => setTimeout(resolve, 100))]);
|
|
589
|
-
|
|
590
|
-
document.body.removeChild(el);
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
it('should handle multiple requestUpdate calls', async () => {
|
|
594
|
-
const tagName = generateUniqueTagName('update-test');
|
|
595
|
-
let renderCount = 0;
|
|
596
|
-
|
|
597
|
-
@customElementConfig({ tagName })
|
|
598
|
-
class UpdateTest extends CustomElement {
|
|
599
|
-
static style = ':host { display: block; }';
|
|
600
|
-
|
|
601
|
-
render() {
|
|
602
|
-
renderCount++;
|
|
603
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const el = document.createElement(tagName) as UpdateTest;
|
|
608
|
-
document.body.appendChild(el);
|
|
609
|
-
await waitForRender(el);
|
|
610
|
-
|
|
611
|
-
const initialCount = renderCount;
|
|
612
|
-
el.requestUpdate();
|
|
613
|
-
el.requestUpdate();
|
|
614
|
-
el.requestUpdate();
|
|
615
|
-
|
|
616
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
617
|
-
// Multiple requestUpdate calls should only trigger one additional render
|
|
618
|
-
expect(renderCount).toBe(initialCount + 1);
|
|
619
|
-
|
|
620
|
-
document.body.removeChild(el);
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
it('should resolve updateComplete after property change', async () => {
|
|
624
|
-
const tagName = generateUniqueTagName('update-test');
|
|
625
|
-
|
|
626
|
-
@customElementConfig({ tagName })
|
|
627
|
-
class UpdateTest extends CustomElement {
|
|
628
|
-
static style = ':host { display: block; }';
|
|
629
|
-
|
|
630
|
-
@property({ type: String })
|
|
631
|
-
value = '';
|
|
632
|
-
|
|
633
|
-
render() {
|
|
634
|
-
return { vnodeSelector: 'div', properties: {}, text: undefined, domNode: null, children: [
|
|
635
|
-
{ vnodeSelector: '', properties: undefined, children: undefined, text: this.value, domNode: null }
|
|
636
|
-
] };
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const el = document.createElement(tagName) as UpdateTest;
|
|
641
|
-
document.body.appendChild(el);
|
|
642
|
-
await waitForRender(el);
|
|
643
|
-
|
|
644
|
-
el.value = 'test';
|
|
645
|
-
const updatePromise = el.updateComplete;
|
|
646
|
-
|
|
647
|
-
await updatePromise;
|
|
648
|
-
expect(el.shadowRoot?.textContent).toContain('test');
|
|
649
|
-
|
|
650
|
-
document.body.removeChild(el);
|
|
651
|
-
});
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
describe('scheduleRender', () => {
|
|
655
|
-
it('should schedule a render via projector', async () => {
|
|
656
|
-
const tagName = generateUniqueTagName('schedule-test');
|
|
657
|
-
let renderCount = 0;
|
|
658
|
-
|
|
659
|
-
@customElementConfig({ tagName })
|
|
660
|
-
class ScheduleTest extends CustomElement {
|
|
661
|
-
static style = ':host { display: block; }';
|
|
662
|
-
|
|
663
|
-
@property({ type: Number })
|
|
664
|
-
count = 0;
|
|
665
|
-
|
|
666
|
-
render() {
|
|
667
|
-
renderCount++;
|
|
668
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const el = document.createElement(tagName) as ScheduleTest;
|
|
673
|
-
document.body.appendChild(el);
|
|
674
|
-
await waitForRender(el);
|
|
675
|
-
|
|
676
|
-
const initialRenderCount = renderCount;
|
|
677
|
-
el.count = 1;
|
|
678
|
-
el.scheduleRender();
|
|
679
|
-
|
|
680
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
681
|
-
expect(renderCount).toBeGreaterThan(initialRenderCount);
|
|
682
|
-
|
|
683
|
-
document.body.removeChild(el);
|
|
684
|
-
});
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
describe('Form internals', () => {
|
|
688
|
-
it('should provide access to internals for form-associated elements', async () => {
|
|
689
|
-
const tagName = generateUniqueTagName('form-internals-test');
|
|
690
|
-
|
|
691
|
-
@customElementConfig({ tagName })
|
|
692
|
-
class FormInternalsTest extends CustomElement {
|
|
693
|
-
static formAssociated = true;
|
|
694
|
-
static style = ':host { display: inline-block; }';
|
|
695
|
-
|
|
696
|
-
render() {
|
|
697
|
-
return { vnodeSelector: 'input', properties: {}, children: [], text: undefined, domNode: null };
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
const el = document.createElement(tagName) as FormInternalsTest;
|
|
702
|
-
document.body.appendChild(el);
|
|
703
|
-
await waitForRender(el);
|
|
704
|
-
|
|
705
|
-
expect(el.internals).toBeDefined();
|
|
706
|
-
expect(el.internals).not.toBeNull();
|
|
707
|
-
|
|
708
|
-
document.body.removeChild(el);
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
it('should return null for non-form-associated elements', async () => {
|
|
712
|
-
const tagName = generateUniqueTagName('non-form-test');
|
|
713
|
-
|
|
714
|
-
@customElementConfig({ tagName })
|
|
715
|
-
class NonFormTest extends CustomElement {
|
|
716
|
-
static style = ':host { display: block; }';
|
|
717
|
-
|
|
718
|
-
render() {
|
|
719
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
const el = document.createElement(tagName) as NonFormTest;
|
|
724
|
-
document.body.appendChild(el);
|
|
725
|
-
await waitForRender(el);
|
|
726
|
-
|
|
727
|
-
expect(el.internals).toBeNull();
|
|
728
|
-
|
|
729
|
-
document.body.removeChild(el);
|
|
730
|
-
});
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
describe('Shadow DOM configuration', () => {
|
|
734
|
-
it('should support delegatesFocus option', async () => {
|
|
735
|
-
const tagName = generateUniqueTagName('focus-test');
|
|
736
|
-
|
|
737
|
-
@customElementConfig({ tagName })
|
|
738
|
-
class FocusTest extends CustomElement {
|
|
739
|
-
static style = ':host { display: block; }';
|
|
740
|
-
static delegatesFocus = true;
|
|
741
|
-
|
|
742
|
-
render() {
|
|
743
|
-
return { vnodeSelector: 'input', properties: {}, children: [], text: undefined, domNode: null };
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
const el = document.createElement(tagName) as FocusTest;
|
|
748
|
-
document.body.appendChild(el);
|
|
749
|
-
await waitForRender(el);
|
|
750
|
-
|
|
751
|
-
expect(el.shadowRoot).toBeDefined();
|
|
752
|
-
// delegatesFocus is set during attachShadow
|
|
753
|
-
|
|
754
|
-
document.body.removeChild(el);
|
|
755
|
-
});
|
|
756
|
-
});
|
|
757
|
-
|
|
758
|
-
describe('Attribute converters', () => {
|
|
759
|
-
it('should handle Number type attributes', async () => {
|
|
760
|
-
const tagName = generateUniqueTagName('number-attr-test');
|
|
761
|
-
|
|
762
|
-
@customElementConfig({ tagName })
|
|
763
|
-
class NumberAttrTest extends CustomElement {
|
|
764
|
-
static style = ':host { display: block; }';
|
|
765
|
-
|
|
766
|
-
@property({ type: Number })
|
|
767
|
-
count = 0;
|
|
768
|
-
|
|
769
|
-
render() {
|
|
770
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
const el = document.createElement(tagName) as NumberAttrTest;
|
|
775
|
-
document.body.appendChild(el);
|
|
776
|
-
await waitForRender(el);
|
|
777
|
-
|
|
778
|
-
el.setAttribute('count', '42');
|
|
779
|
-
await new Promise(resolve => setTimeout(resolve, 10));
|
|
780
|
-
expect(el.count).toBe(42);
|
|
781
|
-
|
|
782
|
-
el.setAttribute('count', '0');
|
|
783
|
-
await new Promise(resolve => setTimeout(resolve, 10));
|
|
784
|
-
expect(el.count).toBe(0);
|
|
785
|
-
|
|
786
|
-
document.body.removeChild(el);
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
it('should handle custom converter', async () => {
|
|
790
|
-
const tagName = generateUniqueTagName('converter-test');
|
|
791
|
-
|
|
792
|
-
@customElementConfig({ tagName })
|
|
793
|
-
class ConverterTest extends CustomElement {
|
|
794
|
-
static style = ':host { display: block; }';
|
|
795
|
-
|
|
796
|
-
@property({
|
|
797
|
-
type: Object,
|
|
798
|
-
converter: {
|
|
799
|
-
fromAttribute: (value: string | null) => {
|
|
800
|
-
return value ? JSON.parse(value) : null;
|
|
801
|
-
},
|
|
802
|
-
toAttribute: (value: any) => {
|
|
803
|
-
return value ? JSON.stringify(value) : null;
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
})
|
|
807
|
-
data: any = null;
|
|
808
|
-
|
|
809
|
-
render() {
|
|
810
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
const el = document.createElement(tagName) as ConverterTest;
|
|
815
|
-
document.body.appendChild(el);
|
|
816
|
-
await waitForRender(el);
|
|
817
|
-
|
|
818
|
-
el.setAttribute('data', '{"key":"value"}');
|
|
819
|
-
await new Promise(resolve => setTimeout(resolve, 10));
|
|
820
|
-
expect(el.data).toEqual({ key: 'value' });
|
|
821
|
-
|
|
822
|
-
document.body.removeChild(el);
|
|
823
|
-
});
|
|
824
|
-
});
|
|
825
|
-
|
|
826
|
-
describe('Edge cases', () => {
|
|
827
|
-
it('should handle renderNow when shadowRoot is not available', async () => {
|
|
828
|
-
const tagName = generateUniqueTagName('no-shadow-test');
|
|
829
|
-
|
|
830
|
-
@customElementConfig({ tagName })
|
|
831
|
-
class NoShadowTest extends CustomElement {
|
|
832
|
-
// No style means no shadow root initially
|
|
833
|
-
render() {
|
|
834
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
const el = document.createElement(tagName) as NoShadowTest;
|
|
839
|
-
|
|
840
|
-
// Call renderNow before connection (no shadow root)
|
|
841
|
-
el.renderNow(); // Should not throw
|
|
842
|
-
|
|
843
|
-
document.body.appendChild(el);
|
|
844
|
-
await waitForRender(el);
|
|
845
|
-
document.body.removeChild(el);
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
it('should handle disconnection during render', async () => {
|
|
849
|
-
const tagName = generateUniqueTagName('disconnect-test');
|
|
850
|
-
|
|
851
|
-
@customElementConfig({ tagName })
|
|
852
|
-
class DisconnectTest extends CustomElement {
|
|
853
|
-
static style = ':host { display: block; }';
|
|
854
|
-
|
|
855
|
-
render() {
|
|
856
|
-
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
const el = document.createElement(tagName) as DisconnectTest;
|
|
861
|
-
document.body.appendChild(el);
|
|
862
|
-
await waitForRender(el);
|
|
863
|
-
|
|
864
|
-
// Disconnect and verify cleanup
|
|
865
|
-
document.body.removeChild(el);
|
|
866
|
-
|
|
867
|
-
// Should not throw when scheduling render after disconnect
|
|
868
|
-
el.scheduleRender();
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
it('should handle multiple rapid property changes', async () => {
|
|
872
|
-
const tagName = generateUniqueTagName('rapid-test');
|
|
873
|
-
|
|
874
|
-
@customElementConfig({ tagName })
|
|
875
|
-
class RapidTest extends CustomElement {
|
|
876
|
-
static style = ':host { display: block; }';
|
|
877
|
-
|
|
878
|
-
@property({ type: Number })
|
|
879
|
-
value = 0;
|
|
880
|
-
|
|
881
|
-
render() {
|
|
882
|
-
return { vnodeSelector: 'div', properties: {}, text: undefined, domNode: null, children: [
|
|
883
|
-
{ vnodeSelector: '', properties: undefined, children: undefined, text: String(this.value), domNode: null }
|
|
884
|
-
] };
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
const el = document.createElement(tagName) as RapidTest;
|
|
889
|
-
document.body.appendChild(el);
|
|
890
|
-
await waitForRender(el);
|
|
891
|
-
|
|
892
|
-
// Rapid property changes
|
|
893
|
-
el.value = 1;
|
|
894
|
-
el.value = 2;
|
|
895
|
-
el.value = 3;
|
|
896
|
-
el.value = 4;
|
|
897
|
-
el.value = 5;
|
|
898
|
-
|
|
899
|
-
await el.updateComplete;
|
|
900
|
-
expect(el.value).toBe(5);
|
|
901
|
-
expect(el.shadowRoot?.textContent).toContain('5');
|
|
902
|
-
|
|
903
|
-
document.body.removeChild(el);
|
|
904
|
-
});
|
|
905
|
-
});
|
|
906
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CustomElement base class
|
|
3
|
+
* Covers lifecycle, rendering, and update coordination
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import './test-setup.js';
|
|
8
|
+
import { CustomElement } from './custom-element.js';
|
|
9
|
+
import { customElementConfig } from './decorators/custom-element-config.js';
|
|
10
|
+
import { property } from './decorators/property.js';
|
|
11
|
+
import { generateUniqueTagName } from './test-setup.js';
|
|
12
|
+
import { waitForRender } from './test-utils.js';
|
|
13
|
+
|
|
14
|
+
describe('CustomElement', () => {
|
|
15
|
+
describe('Lifecycle', () => {
|
|
16
|
+
it('should create shadow root on connection', async () => {
|
|
17
|
+
const tagName = generateUniqueTagName('lifecycle-test');
|
|
18
|
+
|
|
19
|
+
@customElementConfig({ tagName })
|
|
20
|
+
class LifecycleTest extends CustomElement {
|
|
21
|
+
static style = ':host { display: block; }';
|
|
22
|
+
|
|
23
|
+
render() {
|
|
24
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const el = document.createElement(tagName) as LifecycleTest;
|
|
29
|
+
// Shadow root is created in constructor when static style is defined
|
|
30
|
+
expect(el.shadowRoot).toBeDefined();
|
|
31
|
+
|
|
32
|
+
document.body.appendChild(el);
|
|
33
|
+
await waitForRender(el);
|
|
34
|
+
|
|
35
|
+
expect(el.shadowRoot).toBeDefined();
|
|
36
|
+
|
|
37
|
+
document.body.removeChild(el);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should call init() once', async () => {
|
|
41
|
+
const tagName = generateUniqueTagName('lifecycle-test');
|
|
42
|
+
let initCount = 0;
|
|
43
|
+
|
|
44
|
+
@customElementConfig({ tagName })
|
|
45
|
+
class LifecycleTest extends CustomElement {
|
|
46
|
+
static style = ':host { display: block; }';
|
|
47
|
+
|
|
48
|
+
init() {
|
|
49
|
+
initCount++;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
render() {
|
|
53
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const el = document.createElement(tagName) as LifecycleTest;
|
|
58
|
+
document.body.appendChild(el);
|
|
59
|
+
await waitForRender(el);
|
|
60
|
+
|
|
61
|
+
// Remove and re-add
|
|
62
|
+
document.body.removeChild(el);
|
|
63
|
+
document.body.appendChild(el);
|
|
64
|
+
await waitForRender(el);
|
|
65
|
+
|
|
66
|
+
expect(initCount).toBe(1);
|
|
67
|
+
|
|
68
|
+
document.body.removeChild(el);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should resolve updateComplete promise', async () => {
|
|
72
|
+
const tagName = generateUniqueTagName('lifecycle-test');
|
|
73
|
+
|
|
74
|
+
@customElementConfig({ tagName })
|
|
75
|
+
class LifecycleTest extends CustomElement {
|
|
76
|
+
static style = ':host { display: block; }';
|
|
77
|
+
|
|
78
|
+
@property({ type: String })
|
|
79
|
+
value = '';
|
|
80
|
+
|
|
81
|
+
render() {
|
|
82
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const el = document.createElement(tagName) as LifecycleTest;
|
|
87
|
+
document.body.appendChild(el);
|
|
88
|
+
|
|
89
|
+
el.value = 'test';
|
|
90
|
+
const promise = el.updateComplete;
|
|
91
|
+
|
|
92
|
+
expect(promise).toBeInstanceOf(Promise);
|
|
93
|
+
await promise;
|
|
94
|
+
|
|
95
|
+
expect(el.value).toBe('test');
|
|
96
|
+
|
|
97
|
+
document.body.removeChild(el);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('Rendering', () => {
|
|
102
|
+
it('should render content to shadow DOM', async () => {
|
|
103
|
+
const tagName = generateUniqueTagName('render-test');
|
|
104
|
+
|
|
105
|
+
@customElementConfig({ tagName })
|
|
106
|
+
class RenderTest extends CustomElement {
|
|
107
|
+
static style = ':host { display: block; }';
|
|
108
|
+
|
|
109
|
+
render() {
|
|
110
|
+
return {
|
|
111
|
+
vnodeSelector: 'div',
|
|
112
|
+
properties: { class: 'content' }, text: undefined, domNode: null,
|
|
113
|
+
children: [
|
|
114
|
+
{ vnodeSelector: 'span', properties: {}, text: 'Hello World', domNode: null, children: undefined },
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const el = document.createElement(tagName) as RenderTest;
|
|
121
|
+
document.body.appendChild(el);
|
|
122
|
+
await waitForRender(el);
|
|
123
|
+
|
|
124
|
+
const div = el.shadowRoot?.querySelector('div');
|
|
125
|
+
expect(div).toBeDefined();
|
|
126
|
+
expect(div?.className).toBe('content');
|
|
127
|
+
|
|
128
|
+
const span = el.shadowRoot?.querySelector('span');
|
|
129
|
+
expect(span).toBeDefined();
|
|
130
|
+
expect(span?.textContent).toBe('Hello World');
|
|
131
|
+
|
|
132
|
+
document.body.removeChild(el);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should re-render when renderNow() is called', async () => {
|
|
136
|
+
const tagName = generateUniqueTagName('render-test');
|
|
137
|
+
let renderCount = 0;
|
|
138
|
+
|
|
139
|
+
@customElementConfig({ tagName })
|
|
140
|
+
class RenderTest extends CustomElement {
|
|
141
|
+
static style = ':host { display: block; }';
|
|
142
|
+
|
|
143
|
+
render() {
|
|
144
|
+
renderCount++;
|
|
145
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const el = document.createElement(tagName) as RenderTest;
|
|
150
|
+
document.body.appendChild(el);
|
|
151
|
+
await waitForRender(el);
|
|
152
|
+
|
|
153
|
+
const initialCount = renderCount;
|
|
154
|
+
el.renderNow();
|
|
155
|
+
|
|
156
|
+
expect(renderCount).toBeGreaterThan(initialCount);
|
|
157
|
+
|
|
158
|
+
document.body.removeChild(el);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should inject static style into shadow DOM', async () => {
|
|
162
|
+
const tagName = generateUniqueTagName('style-test');
|
|
163
|
+
|
|
164
|
+
@customElementConfig({ tagName })
|
|
165
|
+
class StyleTest extends CustomElement {
|
|
166
|
+
static style = 'div { color: red; }';
|
|
167
|
+
|
|
168
|
+
render() {
|
|
169
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const el = document.createElement(tagName) as StyleTest;
|
|
174
|
+
document.body.appendChild(el);
|
|
175
|
+
await waitForRender(el);
|
|
176
|
+
|
|
177
|
+
// Check that shadow root exists and has content
|
|
178
|
+
expect(el.shadowRoot).toBeDefined();
|
|
179
|
+
const div = el.shadowRoot?.querySelector('div');
|
|
180
|
+
expect(div).toBeDefined();
|
|
181
|
+
|
|
182
|
+
document.body.removeChild(el);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('Update coordination', () => {
|
|
187
|
+
it('should call updated() after property changes', async () => {
|
|
188
|
+
const tagName = generateUniqueTagName('update-test');
|
|
189
|
+
const updates: string[] = [];
|
|
190
|
+
|
|
191
|
+
@customElementConfig({ tagName })
|
|
192
|
+
class UpdateTest extends CustomElement {
|
|
193
|
+
static style = ':host { display: block; }';
|
|
194
|
+
|
|
195
|
+
@property({ type: String })
|
|
196
|
+
value = '';
|
|
197
|
+
|
|
198
|
+
updated(propertyKey: string) {
|
|
199
|
+
updates.push(propertyKey);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
render() {
|
|
203
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const el = document.createElement(tagName) as UpdateTest;
|
|
208
|
+
document.body.appendChild(el);
|
|
209
|
+
|
|
210
|
+
el.value = 'changed';
|
|
211
|
+
await waitForRender(el);
|
|
212
|
+
|
|
213
|
+
expect(updates).toContain('value');
|
|
214
|
+
|
|
215
|
+
document.body.removeChild(el);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should block update when shouldUpdate() returns false', async () => {
|
|
219
|
+
const tagName = generateUniqueTagName('update-test');
|
|
220
|
+
|
|
221
|
+
@customElementConfig({ tagName })
|
|
222
|
+
class UpdateTest extends CustomElement {
|
|
223
|
+
static style = ':host { display: block; }';
|
|
224
|
+
|
|
225
|
+
@property({ type: Number })
|
|
226
|
+
count = 0;
|
|
227
|
+
|
|
228
|
+
shouldUpdate(propertyKey: string, oldValue: any, newValue: any): boolean {
|
|
229
|
+
return newValue !== 5; // Block updates to 5
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
render() {
|
|
233
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const el = document.createElement(tagName) as UpdateTest;
|
|
238
|
+
document.body.appendChild(el);
|
|
239
|
+
|
|
240
|
+
el.count = 5;
|
|
241
|
+
await waitForRender(el);
|
|
242
|
+
expect(el.count).toBe(0); // Should not update
|
|
243
|
+
|
|
244
|
+
el.count = 10;
|
|
245
|
+
await waitForRender(el);
|
|
246
|
+
expect(el.count).toBe(10); // Should update
|
|
247
|
+
|
|
248
|
+
document.body.removeChild(el);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('Form association', () => {
|
|
253
|
+
it('should support form-associated custom elements', async () => {
|
|
254
|
+
const tagName = generateUniqueTagName('form-test');
|
|
255
|
+
|
|
256
|
+
@customElementConfig({ tagName })
|
|
257
|
+
class FormTest extends CustomElement {
|
|
258
|
+
static formAssociated = true;
|
|
259
|
+
static style = ':host { display: inline-block; }';
|
|
260
|
+
|
|
261
|
+
@property({ type: String })
|
|
262
|
+
value = '';
|
|
263
|
+
|
|
264
|
+
render() {
|
|
265
|
+
return { vnodeSelector: 'input', properties: {}, children: [], text: undefined, domNode: null };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const form = document.createElement('form');
|
|
270
|
+
const el = document.createElement(tagName) as FormTest;
|
|
271
|
+
form.appendChild(el);
|
|
272
|
+
document.body.appendChild(form);
|
|
273
|
+
await waitForRender(el);
|
|
274
|
+
|
|
275
|
+
expect(el.internals).toBeDefined();
|
|
276
|
+
|
|
277
|
+
document.body.removeChild(form);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('Attribute changes', () => {
|
|
282
|
+
it('should sync attribute changes to properties', async () => {
|
|
283
|
+
const tagName = generateUniqueTagName('attr-test');
|
|
284
|
+
|
|
285
|
+
@customElementConfig({ tagName })
|
|
286
|
+
class AttrTest extends CustomElement {
|
|
287
|
+
static style = ':host { display: block; }';
|
|
288
|
+
|
|
289
|
+
@property({ type: String })
|
|
290
|
+
message = '';
|
|
291
|
+
|
|
292
|
+
render() {
|
|
293
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const el = document.createElement(tagName) as AttrTest;
|
|
298
|
+
document.body.appendChild(el);
|
|
299
|
+
await waitForRender(el);
|
|
300
|
+
|
|
301
|
+
el.setAttribute('message', 'hello');
|
|
302
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
303
|
+
expect(el.message).toBe('hello');
|
|
304
|
+
|
|
305
|
+
document.body.removeChild(el);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should handle boolean attributes', async () => {
|
|
309
|
+
const tagName = generateUniqueTagName('attr-test');
|
|
310
|
+
|
|
311
|
+
@customElementConfig({ tagName })
|
|
312
|
+
class AttrTest extends CustomElement {
|
|
313
|
+
static style = ':host { display: block; }';
|
|
314
|
+
|
|
315
|
+
@property({ type: Boolean })
|
|
316
|
+
enabled = false;
|
|
317
|
+
|
|
318
|
+
render() {
|
|
319
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const el = document.createElement(tagName) as AttrTest;
|
|
324
|
+
document.body.appendChild(el);
|
|
325
|
+
await waitForRender(el);
|
|
326
|
+
|
|
327
|
+
el.setAttribute('enabled', '');
|
|
328
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
329
|
+
expect(el.enabled).toBe(true);
|
|
330
|
+
|
|
331
|
+
el.removeAttribute('enabled');
|
|
332
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
333
|
+
expect(el.enabled).toBe(false);
|
|
334
|
+
|
|
335
|
+
document.body.removeChild(el);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe('Projector modes', () => {
|
|
340
|
+
it('should support different projector modes', async () => {
|
|
341
|
+
const tagName = generateUniqueTagName('projector-test');
|
|
342
|
+
|
|
343
|
+
@customElementConfig({ tagName })
|
|
344
|
+
class ProjectorTest extends CustomElement {
|
|
345
|
+
static style = ':host { display: block; }';
|
|
346
|
+
static projectorMode = 'merge';
|
|
347
|
+
|
|
348
|
+
render() {
|
|
349
|
+
return { vnodeSelector: 'div', properties: {}, text: undefined, domNode: null, children: [
|
|
350
|
+
{ vnodeSelector: 'span', properties: {}, children: [], text: 'test', domNode: null }
|
|
351
|
+
] };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const el = document.createElement(tagName) as ProjectorTest;
|
|
356
|
+
document.body.appendChild(el);
|
|
357
|
+
await waitForRender(el);
|
|
358
|
+
|
|
359
|
+
expect(el.shadowRoot).toBeDefined();
|
|
360
|
+
expect(el.shadowRoot.querySelector('span')).toBeDefined();
|
|
361
|
+
|
|
362
|
+
document.body.removeChild(el);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe('Property upgrades', () => {
|
|
367
|
+
it('should upgrade properties set before connection', async () => {
|
|
368
|
+
const tagName = generateUniqueTagName('upgrade-test');
|
|
369
|
+
|
|
370
|
+
@customElementConfig({ tagName })
|
|
371
|
+
class UpgradeTest extends CustomElement {
|
|
372
|
+
static style = ':host { display: block; }';
|
|
373
|
+
|
|
374
|
+
@property({ type: String })
|
|
375
|
+
presetValue = '';
|
|
376
|
+
|
|
377
|
+
render() {
|
|
378
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const el = document.createElement(tagName) as UpgradeTest;
|
|
383
|
+
|
|
384
|
+
// Set property before connecting to DOM
|
|
385
|
+
(el as any).presetValue = 'preset';
|
|
386
|
+
|
|
387
|
+
document.body.appendChild(el);
|
|
388
|
+
await waitForRender(el);
|
|
389
|
+
|
|
390
|
+
expect(el.presetValue).toBe('preset');
|
|
391
|
+
|
|
392
|
+
document.body.removeChild(el);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe('Controllers', () => {
|
|
397
|
+
it('should invoke controller lifecycle methods', async () => {
|
|
398
|
+
const tagName = generateUniqueTagName('controller-test');
|
|
399
|
+
const lifecycle: string[] = [];
|
|
400
|
+
|
|
401
|
+
const controller = {
|
|
402
|
+
connected: () => lifecycle.push('connected'),
|
|
403
|
+
disconnected: () => lifecycle.push('disconnected'),
|
|
404
|
+
hostRenderStart: () => lifecycle.push('renderStart'),
|
|
405
|
+
hostRenderDone: () => lifecycle.push('renderDone'),
|
|
406
|
+
renderNow: () => {},
|
|
407
|
+
hostElement: null as any
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
@customElementConfig({ tagName })
|
|
411
|
+
class ControllerTest extends CustomElement {
|
|
412
|
+
static style = ':host { display: block; }';
|
|
413
|
+
|
|
414
|
+
constructor() {
|
|
415
|
+
super();
|
|
416
|
+
this.addController(controller);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
render() {
|
|
420
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const el = document.createElement(tagName) as ControllerTest;
|
|
425
|
+
document.body.appendChild(el);
|
|
426
|
+
await waitForRender(el);
|
|
427
|
+
|
|
428
|
+
expect(lifecycle).toContain('connected');
|
|
429
|
+
expect(lifecycle).toContain('renderStart');
|
|
430
|
+
expect(lifecycle).toContain('renderDone');
|
|
431
|
+
|
|
432
|
+
document.body.removeChild(el);
|
|
433
|
+
expect(lifecycle).toContain('disconnected');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('should call controller.connected when added after element is connected', async () => {
|
|
437
|
+
const tagName = generateUniqueTagName('controller-test');
|
|
438
|
+
let controllerConnected = false;
|
|
439
|
+
|
|
440
|
+
@customElementConfig({ tagName })
|
|
441
|
+
class ControllerTest extends CustomElement {
|
|
442
|
+
static style = ':host { display: block; }';
|
|
443
|
+
|
|
444
|
+
render() {
|
|
445
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const el = document.createElement(tagName) as ControllerTest;
|
|
450
|
+
document.body.appendChild(el);
|
|
451
|
+
await waitForRender(el);
|
|
452
|
+
|
|
453
|
+
// Add controller after connection
|
|
454
|
+
el.addController({
|
|
455
|
+
connected: () => { controllerConnected = true; },
|
|
456
|
+
renderNow: () => {},
|
|
457
|
+
hostElement: null as any
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
expect(controllerConnected).toBe(true);
|
|
461
|
+
document.body.removeChild(el);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('should handle multiple controllers', async () => {
|
|
465
|
+
const tagName = generateUniqueTagName('controller-test');
|
|
466
|
+
const calls: string[] = [];
|
|
467
|
+
|
|
468
|
+
@customElementConfig({ tagName })
|
|
469
|
+
class ControllerTest extends CustomElement {
|
|
470
|
+
static style = ':host { display: block; }';
|
|
471
|
+
|
|
472
|
+
constructor() {
|
|
473
|
+
super();
|
|
474
|
+
this.addController({
|
|
475
|
+
connected: () => calls.push('controller1-connected'),
|
|
476
|
+
renderNow: () => {},
|
|
477
|
+
hostElement: null as any
|
|
478
|
+
});
|
|
479
|
+
this.addController({
|
|
480
|
+
connected: () => calls.push('controller2-connected'),
|
|
481
|
+
renderNow: () => {},
|
|
482
|
+
hostElement: null as any
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
render() {
|
|
487
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const el = document.createElement(tagName) as ControllerTest;
|
|
492
|
+
document.body.appendChild(el);
|
|
493
|
+
await waitForRender(el);
|
|
494
|
+
|
|
495
|
+
expect(calls).toContain('controller1-connected');
|
|
496
|
+
expect(calls).toContain('controller2-connected');
|
|
497
|
+
|
|
498
|
+
document.body.removeChild(el);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
describe('Render lifecycle hooks', () => {
|
|
503
|
+
it('should call renderStart and renderDone hooks', async () => {
|
|
504
|
+
const tagName = generateUniqueTagName('lifecycle-test');
|
|
505
|
+
const events: string[] = [];
|
|
506
|
+
|
|
507
|
+
@customElementConfig({ tagName })
|
|
508
|
+
class LifecycleTest extends CustomElement {
|
|
509
|
+
static style = ':host { display: block; }';
|
|
510
|
+
|
|
511
|
+
renderStart = (isFirst: boolean) => {
|
|
512
|
+
events.push(`renderStart-${isFirst}`);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
renderDone = (isFirst: boolean) => {
|
|
516
|
+
events.push(`renderDone-${isFirst}`);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
render() {
|
|
520
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const el = document.createElement(tagName) as LifecycleTest;
|
|
525
|
+
document.body.appendChild(el);
|
|
526
|
+
await waitForRender(el);
|
|
527
|
+
|
|
528
|
+
expect(events).toContain('renderStart-true');
|
|
529
|
+
expect(events).toContain('renderDone-true');
|
|
530
|
+
|
|
531
|
+
// Second render should have isFirst=false
|
|
532
|
+
el.renderNow();
|
|
533
|
+
await waitForRender(el);
|
|
534
|
+
|
|
535
|
+
expect(events).toContain('renderStart-false');
|
|
536
|
+
expect(events).toContain('renderDone-false');
|
|
537
|
+
|
|
538
|
+
document.body.removeChild(el);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('should dispatch firstRender event', async () => {
|
|
542
|
+
const tagName = generateUniqueTagName('lifecycle-test');
|
|
543
|
+
let firstRenderDispatched = false;
|
|
544
|
+
|
|
545
|
+
@customElementConfig({ tagName })
|
|
546
|
+
class LifecycleTest extends CustomElement {
|
|
547
|
+
static style = ':host { display: block; }';
|
|
548
|
+
|
|
549
|
+
render() {
|
|
550
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const el = document.createElement(tagName) as LifecycleTest;
|
|
555
|
+
el.addEventListener('firstRender', () => {
|
|
556
|
+
firstRenderDispatched = true;
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
document.body.appendChild(el);
|
|
560
|
+
await waitForRender(el);
|
|
561
|
+
|
|
562
|
+
expect(firstRenderDispatched).toBe(true);
|
|
563
|
+
document.body.removeChild(el);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
describe('requestUpdate and updateComplete', () => {
|
|
568
|
+
it('should return promise for requestUpdate', async () => {
|
|
569
|
+
const tagName = generateUniqueTagName('update-test');
|
|
570
|
+
|
|
571
|
+
@customElementConfig({ tagName })
|
|
572
|
+
class UpdateTest extends CustomElement {
|
|
573
|
+
static style = ':host { display: block; }';
|
|
574
|
+
|
|
575
|
+
render() {
|
|
576
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const el = document.createElement(tagName) as UpdateTest;
|
|
581
|
+
document.body.appendChild(el);
|
|
582
|
+
await waitForRender(el);
|
|
583
|
+
|
|
584
|
+
const promise = el.requestUpdate();
|
|
585
|
+
expect(promise).toBeInstanceOf(Promise);
|
|
586
|
+
|
|
587
|
+
// Wait for the update
|
|
588
|
+
await Promise.race([promise, new Promise(resolve => setTimeout(resolve, 100))]);
|
|
589
|
+
|
|
590
|
+
document.body.removeChild(el);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('should handle multiple requestUpdate calls', async () => {
|
|
594
|
+
const tagName = generateUniqueTagName('update-test');
|
|
595
|
+
let renderCount = 0;
|
|
596
|
+
|
|
597
|
+
@customElementConfig({ tagName })
|
|
598
|
+
class UpdateTest extends CustomElement {
|
|
599
|
+
static style = ':host { display: block; }';
|
|
600
|
+
|
|
601
|
+
render() {
|
|
602
|
+
renderCount++;
|
|
603
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const el = document.createElement(tagName) as UpdateTest;
|
|
608
|
+
document.body.appendChild(el);
|
|
609
|
+
await waitForRender(el);
|
|
610
|
+
|
|
611
|
+
const initialCount = renderCount;
|
|
612
|
+
el.requestUpdate();
|
|
613
|
+
el.requestUpdate();
|
|
614
|
+
el.requestUpdate();
|
|
615
|
+
|
|
616
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
617
|
+
// Multiple requestUpdate calls should only trigger one additional render
|
|
618
|
+
expect(renderCount).toBe(initialCount + 1);
|
|
619
|
+
|
|
620
|
+
document.body.removeChild(el);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('should resolve updateComplete after property change', async () => {
|
|
624
|
+
const tagName = generateUniqueTagName('update-test');
|
|
625
|
+
|
|
626
|
+
@customElementConfig({ tagName })
|
|
627
|
+
class UpdateTest extends CustomElement {
|
|
628
|
+
static style = ':host { display: block; }';
|
|
629
|
+
|
|
630
|
+
@property({ type: String })
|
|
631
|
+
value = '';
|
|
632
|
+
|
|
633
|
+
render() {
|
|
634
|
+
return { vnodeSelector: 'div', properties: {}, text: undefined, domNode: null, children: [
|
|
635
|
+
{ vnodeSelector: '', properties: undefined, children: undefined, text: this.value, domNode: null }
|
|
636
|
+
] };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const el = document.createElement(tagName) as UpdateTest;
|
|
641
|
+
document.body.appendChild(el);
|
|
642
|
+
await waitForRender(el);
|
|
643
|
+
|
|
644
|
+
el.value = 'test';
|
|
645
|
+
const updatePromise = el.updateComplete;
|
|
646
|
+
|
|
647
|
+
await updatePromise;
|
|
648
|
+
expect(el.shadowRoot?.textContent).toContain('test');
|
|
649
|
+
|
|
650
|
+
document.body.removeChild(el);
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
describe('scheduleRender', () => {
|
|
655
|
+
it('should schedule a render via projector', async () => {
|
|
656
|
+
const tagName = generateUniqueTagName('schedule-test');
|
|
657
|
+
let renderCount = 0;
|
|
658
|
+
|
|
659
|
+
@customElementConfig({ tagName })
|
|
660
|
+
class ScheduleTest extends CustomElement {
|
|
661
|
+
static style = ':host { display: block; }';
|
|
662
|
+
|
|
663
|
+
@property({ type: Number })
|
|
664
|
+
count = 0;
|
|
665
|
+
|
|
666
|
+
render() {
|
|
667
|
+
renderCount++;
|
|
668
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const el = document.createElement(tagName) as ScheduleTest;
|
|
673
|
+
document.body.appendChild(el);
|
|
674
|
+
await waitForRender(el);
|
|
675
|
+
|
|
676
|
+
const initialRenderCount = renderCount;
|
|
677
|
+
el.count = 1;
|
|
678
|
+
el.scheduleRender();
|
|
679
|
+
|
|
680
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
681
|
+
expect(renderCount).toBeGreaterThan(initialRenderCount);
|
|
682
|
+
|
|
683
|
+
document.body.removeChild(el);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
describe('Form internals', () => {
|
|
688
|
+
it('should provide access to internals for form-associated elements', async () => {
|
|
689
|
+
const tagName = generateUniqueTagName('form-internals-test');
|
|
690
|
+
|
|
691
|
+
@customElementConfig({ tagName })
|
|
692
|
+
class FormInternalsTest extends CustomElement {
|
|
693
|
+
static formAssociated = true;
|
|
694
|
+
static style = ':host { display: inline-block; }';
|
|
695
|
+
|
|
696
|
+
render() {
|
|
697
|
+
return { vnodeSelector: 'input', properties: {}, children: [], text: undefined, domNode: null };
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const el = document.createElement(tagName) as FormInternalsTest;
|
|
702
|
+
document.body.appendChild(el);
|
|
703
|
+
await waitForRender(el);
|
|
704
|
+
|
|
705
|
+
expect(el.internals).toBeDefined();
|
|
706
|
+
expect(el.internals).not.toBeNull();
|
|
707
|
+
|
|
708
|
+
document.body.removeChild(el);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it('should return null for non-form-associated elements', async () => {
|
|
712
|
+
const tagName = generateUniqueTagName('non-form-test');
|
|
713
|
+
|
|
714
|
+
@customElementConfig({ tagName })
|
|
715
|
+
class NonFormTest extends CustomElement {
|
|
716
|
+
static style = ':host { display: block; }';
|
|
717
|
+
|
|
718
|
+
render() {
|
|
719
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const el = document.createElement(tagName) as NonFormTest;
|
|
724
|
+
document.body.appendChild(el);
|
|
725
|
+
await waitForRender(el);
|
|
726
|
+
|
|
727
|
+
expect(el.internals).toBeNull();
|
|
728
|
+
|
|
729
|
+
document.body.removeChild(el);
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
describe('Shadow DOM configuration', () => {
|
|
734
|
+
it('should support delegatesFocus option', async () => {
|
|
735
|
+
const tagName = generateUniqueTagName('focus-test');
|
|
736
|
+
|
|
737
|
+
@customElementConfig({ tagName })
|
|
738
|
+
class FocusTest extends CustomElement {
|
|
739
|
+
static style = ':host { display: block; }';
|
|
740
|
+
static delegatesFocus = true;
|
|
741
|
+
|
|
742
|
+
render() {
|
|
743
|
+
return { vnodeSelector: 'input', properties: {}, children: [], text: undefined, domNode: null };
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const el = document.createElement(tagName) as FocusTest;
|
|
748
|
+
document.body.appendChild(el);
|
|
749
|
+
await waitForRender(el);
|
|
750
|
+
|
|
751
|
+
expect(el.shadowRoot).toBeDefined();
|
|
752
|
+
// delegatesFocus is set during attachShadow
|
|
753
|
+
|
|
754
|
+
document.body.removeChild(el);
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
describe('Attribute converters', () => {
|
|
759
|
+
it('should handle Number type attributes', async () => {
|
|
760
|
+
const tagName = generateUniqueTagName('number-attr-test');
|
|
761
|
+
|
|
762
|
+
@customElementConfig({ tagName })
|
|
763
|
+
class NumberAttrTest extends CustomElement {
|
|
764
|
+
static style = ':host { display: block; }';
|
|
765
|
+
|
|
766
|
+
@property({ type: Number })
|
|
767
|
+
count = 0;
|
|
768
|
+
|
|
769
|
+
render() {
|
|
770
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const el = document.createElement(tagName) as NumberAttrTest;
|
|
775
|
+
document.body.appendChild(el);
|
|
776
|
+
await waitForRender(el);
|
|
777
|
+
|
|
778
|
+
el.setAttribute('count', '42');
|
|
779
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
780
|
+
expect(el.count).toBe(42);
|
|
781
|
+
|
|
782
|
+
el.setAttribute('count', '0');
|
|
783
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
784
|
+
expect(el.count).toBe(0);
|
|
785
|
+
|
|
786
|
+
document.body.removeChild(el);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it('should handle custom converter', async () => {
|
|
790
|
+
const tagName = generateUniqueTagName('converter-test');
|
|
791
|
+
|
|
792
|
+
@customElementConfig({ tagName })
|
|
793
|
+
class ConverterTest extends CustomElement {
|
|
794
|
+
static style = ':host { display: block; }';
|
|
795
|
+
|
|
796
|
+
@property({
|
|
797
|
+
type: Object,
|
|
798
|
+
converter: {
|
|
799
|
+
fromAttribute: (value: string | null) => {
|
|
800
|
+
return value ? JSON.parse(value) : null;
|
|
801
|
+
},
|
|
802
|
+
toAttribute: (value: any) => {
|
|
803
|
+
return value ? JSON.stringify(value) : null;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
})
|
|
807
|
+
data: any = null;
|
|
808
|
+
|
|
809
|
+
render() {
|
|
810
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const el = document.createElement(tagName) as ConverterTest;
|
|
815
|
+
document.body.appendChild(el);
|
|
816
|
+
await waitForRender(el);
|
|
817
|
+
|
|
818
|
+
el.setAttribute('data', '{"key":"value"}');
|
|
819
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
820
|
+
expect(el.data).toEqual({ key: 'value' });
|
|
821
|
+
|
|
822
|
+
document.body.removeChild(el);
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
describe('Edge cases', () => {
|
|
827
|
+
it('should handle renderNow when shadowRoot is not available', async () => {
|
|
828
|
+
const tagName = generateUniqueTagName('no-shadow-test');
|
|
829
|
+
|
|
830
|
+
@customElementConfig({ tagName })
|
|
831
|
+
class NoShadowTest extends CustomElement {
|
|
832
|
+
// No style means no shadow root initially
|
|
833
|
+
render() {
|
|
834
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const el = document.createElement(tagName) as NoShadowTest;
|
|
839
|
+
|
|
840
|
+
// Call renderNow before connection (no shadow root)
|
|
841
|
+
el.renderNow(); // Should not throw
|
|
842
|
+
|
|
843
|
+
document.body.appendChild(el);
|
|
844
|
+
await waitForRender(el);
|
|
845
|
+
document.body.removeChild(el);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it('should handle disconnection during render', async () => {
|
|
849
|
+
const tagName = generateUniqueTagName('disconnect-test');
|
|
850
|
+
|
|
851
|
+
@customElementConfig({ tagName })
|
|
852
|
+
class DisconnectTest extends CustomElement {
|
|
853
|
+
static style = ':host { display: block; }';
|
|
854
|
+
|
|
855
|
+
render() {
|
|
856
|
+
return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const el = document.createElement(tagName) as DisconnectTest;
|
|
861
|
+
document.body.appendChild(el);
|
|
862
|
+
await waitForRender(el);
|
|
863
|
+
|
|
864
|
+
// Disconnect and verify cleanup
|
|
865
|
+
document.body.removeChild(el);
|
|
866
|
+
|
|
867
|
+
// Should not throw when scheduling render after disconnect
|
|
868
|
+
el.scheduleRender();
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('should handle multiple rapid property changes', async () => {
|
|
872
|
+
const tagName = generateUniqueTagName('rapid-test');
|
|
873
|
+
|
|
874
|
+
@customElementConfig({ tagName })
|
|
875
|
+
class RapidTest extends CustomElement {
|
|
876
|
+
static style = ':host { display: block; }';
|
|
877
|
+
|
|
878
|
+
@property({ type: Number })
|
|
879
|
+
value = 0;
|
|
880
|
+
|
|
881
|
+
render() {
|
|
882
|
+
return { vnodeSelector: 'div', properties: {}, text: undefined, domNode: null, children: [
|
|
883
|
+
{ vnodeSelector: '', properties: undefined, children: undefined, text: String(this.value), domNode: null }
|
|
884
|
+
] };
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const el = document.createElement(tagName) as RapidTest;
|
|
889
|
+
document.body.appendChild(el);
|
|
890
|
+
await waitForRender(el);
|
|
891
|
+
|
|
892
|
+
// Rapid property changes
|
|
893
|
+
el.value = 1;
|
|
894
|
+
el.value = 2;
|
|
895
|
+
el.value = 3;
|
|
896
|
+
el.value = 4;
|
|
897
|
+
el.value = 5;
|
|
898
|
+
|
|
899
|
+
await el.updateComplete;
|
|
900
|
+
expect(el.value).toBe(5);
|
|
901
|
+
expect(el.shadowRoot?.textContent).toContain('5');
|
|
902
|
+
|
|
903
|
+
document.body.removeChild(el);
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
});
|