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.
Files changed (64) hide show
  1. package/.editorconfig +17 -17
  2. package/.gitlab-ci.yml +18 -18
  3. package/CHANGELOG.md +201 -201
  4. package/demo/sample.js +1 -1
  5. package/demo/screen.css +16 -16
  6. package/dist/p-elements-core-modern.js +1 -1
  7. package/dist/p-elements-core.js +1 -1
  8. package/docs/package-lock.json +6897 -6897
  9. package/docs/package.json +27 -27
  10. package/docs/src/404.md +8 -8
  11. package/docs/src/_data/demos/hello-world/hello-world.tsx +35 -35
  12. package/docs/src/_data/demos/hello-world/index.html +10 -10
  13. package/docs/src/_data/demos/hello-world/project.json +7 -7
  14. package/docs/src/_data/demos/timer/demo-timer.tsx +120 -120
  15. package/docs/src/_data/demos/timer/icons.tsx +62 -62
  16. package/docs/src/_data/demos/timer/index.html +12 -12
  17. package/docs/src/_data/demos/timer/project.json +8 -8
  18. package/docs/src/_data/global.js +13 -13
  19. package/docs/src/_data/helpers.js +19 -19
  20. package/docs/src/_includes/layouts/base.njk +30 -30
  21. package/docs/src/_includes/layouts/playground.njk +40 -40
  22. package/docs/src/_includes/partials/app-header.njk +8 -8
  23. package/docs/src/_includes/partials/head.njk +14 -14
  24. package/docs/src/_includes/partials/nav.njk +19 -19
  25. package/docs/src/_includes/partials/top-nav.njk +51 -51
  26. package/docs/src/documentation/custom-element.md +221 -221
  27. package/docs/src/documentation/decorators/bind.md +71 -71
  28. package/docs/src/documentation/decorators/custom-element-config.md +63 -63
  29. package/docs/src/documentation/decorators/property.md +83 -83
  30. package/docs/src/documentation/decorators/query.md +66 -66
  31. package/docs/src/documentation/decorators/render-property-on-set.md +60 -60
  32. package/docs/src/documentation/decorators.md +9 -9
  33. package/docs/src/documentation/reactive-properties.md +53 -53
  34. package/docs/src/index.d.ts +25 -25
  35. package/docs/src/index.md +3 -3
  36. package/docs/src/scripts/components/app-mode-switch/app-mode-switch.css +78 -78
  37. package/docs/src/scripts/components/app-mode-switch/app-mode-switch.tsx +166 -166
  38. package/docs/src/scripts/components/app-playground/app-playground.tsx +189 -189
  39. package/docs/tsconfig.json +22 -22
  40. package/index.html +10 -2
  41. package/package.json +1 -1
  42. package/readme.md +206 -206
  43. package/src/custom-element-controller.ts +31 -31
  44. package/src/custom-element.test.ts +906 -906
  45. package/src/custom-element.ts +3 -8
  46. package/src/decorators/bind.test.ts +163 -163
  47. package/src/decorators/bind.ts +46 -46
  48. package/src/decorators/custom-element-config.ts +17 -17
  49. package/src/decorators/property.test.ts +279 -279
  50. package/src/decorators/query.test.ts +146 -146
  51. package/src/decorators/query.ts +12 -12
  52. package/src/decorators/render-property-on-set.ts +3 -3
  53. package/src/helpers/css.ts +71 -71
  54. package/src/maquette/cache.ts +35 -35
  55. package/src/maquette/dom.ts +115 -115
  56. package/src/maquette/h.ts +100 -100
  57. package/src/maquette/index.ts +12 -12
  58. package/src/maquette/interfaces.ts +536 -536
  59. package/src/maquette/jsx.ts +61 -61
  60. package/src/maquette/mapping.ts +56 -56
  61. package/src/maquette/projection.ts +666 -666
  62. package/src/maquette/projector.ts +205 -205
  63. package/src/sample/mixin/highlight.tsx +33 -33
  64. 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
+ });