p-elements-core 1.2.32-rc1 → 1.2.32-rc11

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.
@@ -6,6 +6,7 @@ import {
6
6
 
7
7
  import { ICustomElementController } from "./custom-element-controller";
8
8
  import { Projector, VNode } from "./maquette/interfaces";
9
+ import { replaceApplyToCssVars } from "./helpers/css";
9
10
 
10
11
  export type ElementProjectorMode = "append" | "merge" | "replace";
11
12
 
@@ -70,7 +71,7 @@ export abstract class CustomElement extends HTMLElement {
70
71
 
71
72
  #internalsObjectUntilAttached: object | null;
72
73
 
73
- #controllers: ICustomElementController[] = [];
74
+ readonly #controllers: ICustomElementController[] = [];
74
75
 
75
76
  /** Promise that resolves when the current update is complete */
76
77
  #updatePromise: Promise<void> | null = null;
@@ -124,6 +125,20 @@ export abstract class CustomElement extends HTMLElement {
124
125
  return this.#connected;
125
126
  }
126
127
 
128
+ /**
129
+ * Array of reactive property metadata for the component, indexed by property name.
130
+ * Used by @property decorator and attributeChangedCallback
131
+ *
132
+ * @returns {readonly PropertyOptionsWithName[]} Array of property metadata
133
+ */
134
+ get properties(): readonly PropertyOptionsWithName[] {
135
+ const ctor = this.constructor as ComponentConstructor;
136
+ if (!ctor._propertyInfo) {
137
+ return [];
138
+ }
139
+ return Array.from(ctor._propertyInfo.values());
140
+ }
141
+
127
142
  /**
128
143
  * Promise that resolves when the component has finished updating
129
144
  * and rendering to the DOM.
@@ -216,7 +231,7 @@ export abstract class CustomElement extends HTMLElement {
216
231
  * @public
217
232
  */
218
233
  renderNow(): void {
219
- if (!this.shadowRoot) {
234
+ if (this.#useShadowRoot && !this.shadowRoot) {
220
235
  return;
221
236
  }
222
237
 
@@ -226,8 +241,6 @@ export abstract class CustomElement extends HTMLElement {
226
241
  this.#updateResolve = resolve;
227
242
  });
228
243
  }
229
- // this.#projector.
230
- // render(this.shadowRoot, this.render());
231
244
  this.#projector?.renderNow();
232
245
 
233
246
  // Call updated() lifecycle hook after DOM updates are complete
@@ -299,6 +312,7 @@ export abstract class CustomElement extends HTMLElement {
299
312
  if (!this.#cssSheet) {
300
313
  this.#cssSheet = new CSSStyleSheet();
301
314
  }
315
+ style = this.#polyfillCssApply();
302
316
  this.#cssSheet.replaceSync(style);
303
317
  if (this.#isSheetAdopted) {
304
318
  return;
@@ -320,6 +334,7 @@ export abstract class CustomElement extends HTMLElement {
320
334
  } else {
321
335
  this.#linkElement = document.createElement("link");
322
336
  this.#linkElement.rel = "stylesheet";
337
+ style = this.#polyfillCssApply();
323
338
  this.#linkElement.href = URL.createObjectURL(
324
339
  new Blob([style], { type: "text/css" }),
325
340
  );
@@ -357,6 +372,9 @@ export abstract class CustomElement extends HTMLElement {
357
372
  this.#initStylesheet(styleElement.textContent);
358
373
  styleElement.remove();
359
374
  }
375
+ window.addEventListener("updatecssapply", () => {
376
+ this.#polyfillCssApply();
377
+ });
360
378
 
361
379
  return fragment;
362
380
  }
@@ -368,10 +386,14 @@ export abstract class CustomElement extends HTMLElement {
368
386
  this.addStylesheetToRootNode(css, root);
369
387
  }
370
388
 
371
- protected createProjector(
389
+ protected async createProjector(
372
390
  element: Element,
373
391
  render: () => VNode,
374
392
  ): Promise<Projector> {
393
+ return this.#createProjector(element, render);
394
+ }
395
+
396
+ #createProjector(element: Element, render: () => VNode): Promise<Projector> {
375
397
  return new Promise<Projector>((resolve, reject) => {
376
398
  let projector: Projector;
377
399
  const mode = this.#projectorMode ? this.#projectorMode : "append";
@@ -381,7 +403,7 @@ export abstract class CustomElement extends HTMLElement {
381
403
  if (eventName === "renderStart" || eventName === "renderDone") {
382
404
  this.#invokeRenderLifecycleFn(eventName);
383
405
  }
384
- }
406
+ },
385
407
  });
386
408
  projector[mode](element, render.bind(this));
387
409
  this.#projector = projector;
@@ -396,19 +418,29 @@ export abstract class CustomElement extends HTMLElement {
396
418
  if (typeof (this as any).init === "function") {
397
419
  (this as any).init();
398
420
  this.#controllers.forEach((controller) => {
399
- controller?.init();
421
+ if (controller?.init) {
422
+ controller.init();
423
+ }
400
424
  });
401
425
  }
402
426
  }
403
427
 
404
428
  #invokeRenderLifecycleFn(eventName: string) {
405
- if (this[eventName]){
406
- this[eventName](eventName === "renderStart" ? this.#isFirstRenderStart : this.#isFirstRenderDone);
429
+ if (this[eventName]) {
430
+ this[eventName](
431
+ eventName === "renderStart"
432
+ ? this.#isFirstRenderStart
433
+ : this.#isFirstRenderDone,
434
+ );
407
435
  }
408
436
  const controllerEventName = `host${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`;
409
437
  this.#controllers.forEach((controller) => {
410
438
  if (controller[controllerEventName]) {
411
- controller[controllerEventName](eventName === "renderStart" ? this.#isFirstRenderStart : this.#isFirstRenderDone);
439
+ controller[controllerEventName](
440
+ eventName === "renderStart"
441
+ ? this.#isFirstRenderStart
442
+ : this.#isFirstRenderDone,
443
+ );
412
444
  }
413
445
  });
414
446
  if (eventName === "renderStart") {
@@ -465,14 +497,33 @@ export abstract class CustomElement extends HTMLElement {
465
497
  const div = document.createElement("div");
466
498
  this.shadowRoot.appendChild(div);
467
499
  requestAnimationFrame(() => {
468
- this.createProjector(div, (this as any).render).then(() => {
500
+ this.#createProjector(div, (this as any).render).then(() => {
469
501
  this.#upgradeProperties();
470
- })
502
+ });
503
+ });
504
+ window.addEventListener("updatecssapply", () => {
505
+ this.#polyfillCssApply();
471
506
  });
472
507
  }
473
508
  }
474
509
 
475
-
510
+ #polyfillCssApply(): string {
511
+ let style = replaceApplyToCssVars(this.#cssText);
512
+ if (this.#cssText !== style) {
513
+ this.#cssText = style;
514
+ if (this.#hasAdoptedStyleSheetsSupport && this.#cssSheet) {
515
+ this.#cssSheet.replaceSync(style);
516
+ } else if (!this.#hasAdoptedStyleSheetsSupport) {
517
+ if (this.#linkElement) {
518
+ URL.revokeObjectURL(this.#linkElement.href);
519
+ }
520
+ this.#linkElement.href = URL.createObjectURL(
521
+ new Blob([style], { type: "text/css" }),
522
+ );
523
+ }
524
+ }
525
+ return style;
526
+ }
476
527
 
477
528
  #initStylesheet(style: string) {
478
529
  this.#cssText = style;
@@ -501,6 +552,9 @@ export abstract class CustomElement extends HTMLElement {
501
552
  * Initializes controllers, processes pending property updates, and renders.
502
553
  */
503
554
  connectedCallback(): void {
555
+ if (this.#connected) {
556
+ return;
557
+ }
504
558
  this.#connected = true;
505
559
  let i = 0;
506
560
  const controllersLength = this.#controllers.length;
@@ -521,6 +575,9 @@ export abstract class CustomElement extends HTMLElement {
521
575
  * Marks the component as disconnected.
522
576
  */
523
577
  disconnectedCallback(): void {
578
+ if (this.#connected === false) {
579
+ return;
580
+ }
524
581
  this.#connected = false;
525
582
  let i = 0;
526
583
  const controllersLength = this.#controllers.length;
@@ -539,21 +596,21 @@ export abstract class CustomElement extends HTMLElement {
539
596
  * Prevents infinite loops by skipping updates initiated by property setters
540
597
  *
541
598
  * @param {string} name - The name of the changed attribute
542
- * @param {string | null} _oldValue - The previous attribute value (unused)
599
+ * @param {string | null} oldValue - The previous attribute value (unused)
543
600
  * @param {string | null} newValue - The new attribute value
544
601
  *
545
602
  * @private
546
603
  */
547
604
  attributeChangedCallback(
548
605
  name: string,
549
- _oldValue: string | null,
606
+ oldValue: string | null,
550
607
  newValue: string | null,
551
608
  ): void {
552
609
  // Skip if this attribute change came from a property setter (prevent infinite loop)
553
- if (isSettingAttribute(this)) {
610
+ // Also skip if the value didn't actually change (some browsers may call this callback even if the value is the same)
611
+ if (isSettingAttribute(this) || newValue === oldValue) {
554
612
  return;
555
613
  }
556
-
557
614
  const ctor = this.constructor as ComponentConstructor;
558
615
  if (!ctor?._propertyInfo) {
559
616
  return;
@@ -28,4 +28,7 @@ customElements.whenDefined("custom-style").then(() => {
28
28
  document.body.parentElement.classList.add("custom-style-defined");
29
29
  });
30
30
 
31
- customElements.define("custom-style", CustomStyleElement, { extends: "link" });
31
+ // Only define if not already defined
32
+ if (!customElements.get("custom-style")) {
33
+ customElements.define("custom-style", CustomStyleElement, { extends: "link" });
34
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Tests for @bind decorator
3
+ * Covers method binding to preserve 'this' context
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import '../test-setup.js';
8
+ import { bind } from './bind.js';
9
+ import { CustomElement } from '../custom-element.js';
10
+ import { customElementConfig } from './custom-element-config.js';
11
+ import { generateUniqueTagName } from '../test-setup.js';
12
+ import { waitForRender } from '../test-utils.js';
13
+
14
+ describe('@bind decorator', () => {
15
+ it('should bind method to instance', async () => {
16
+ const tagName = generateUniqueTagName('bind-test');
17
+
18
+ @customElementConfig({ tagName })
19
+ class BindTest extends CustomElement {
20
+ static style = ':host { display: block; }';
21
+ value = 'bound';
22
+
23
+ @bind
24
+ getValue() {
25
+ return this.value;
26
+ }
27
+
28
+ render() {
29
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
30
+ }
31
+ }
32
+
33
+ const el = document.createElement(tagName) as BindTest;
34
+ document.body.appendChild(el);
35
+ await waitForRender(el);
36
+
37
+ const method = el.getValue;
38
+ expect(method()).toBe('bound');
39
+
40
+ document.body.removeChild(el);
41
+ });
42
+
43
+ it('should preserve this context when method is extracted', async () => {
44
+ const tagName = generateUniqueTagName('bind-test');
45
+
46
+ @customElementConfig({ tagName })
47
+ class BindTest extends CustomElement {
48
+ static style = ':host { display: block; }';
49
+ name = 'test-element';
50
+
51
+ @bind
52
+ getName() {
53
+ return this.name;
54
+ }
55
+
56
+ render() {
57
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
58
+ }
59
+ }
60
+
61
+ const el = document.createElement(tagName) as BindTest;
62
+ document.body.appendChild(el);
63
+ await waitForRender(el);
64
+
65
+ const { getName } = el;
66
+ expect(getName()).toBe('test-element');
67
+
68
+ document.body.removeChild(el);
69
+ });
70
+
71
+ it('should work with callbacks', async () => {
72
+ const tagName = generateUniqueTagName('bind-test');
73
+ let result: string;
74
+
75
+ @customElementConfig({ tagName })
76
+ class BindTest extends CustomElement {
77
+ static style = ':host { display: block; }';
78
+ message = 'callback test';
79
+
80
+ @bind
81
+ handleCallback() {
82
+ return this.message;
83
+ }
84
+
85
+ render() {
86
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
87
+ }
88
+ }
89
+
90
+ const el = document.createElement(tagName) as BindTest;
91
+ document.body.appendChild(el);
92
+ await waitForRender(el);
93
+
94
+ const callback = el.handleCallback;
95
+ result = callback();
96
+
97
+ expect(result).toBe('callback test');
98
+
99
+ document.body.removeChild(el);
100
+ });
101
+
102
+ it('should handle methods with arguments', async () => {
103
+ const tagName = generateUniqueTagName('bind-test');
104
+
105
+ @customElementConfig({ tagName })
106
+ class BindTest extends CustomElement {
107
+ static style = ':host { display: block; }';
108
+
109
+ prefixValue = 'Hello';
110
+
111
+ @bind
112
+ greet(name: string) {
113
+ return `${this.prefixValue}, ${name}!`;
114
+ }
115
+
116
+ render() {
117
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
118
+ }
119
+ }
120
+
121
+ const el = document.createElement(tagName) as BindTest;
122
+ document.body.appendChild(el);
123
+ await waitForRender(el);
124
+
125
+ const extracted = el.greet;
126
+ expect(extracted('World')).toBe('Hello, World!');
127
+
128
+ document.body.removeChild(el);
129
+ });
130
+
131
+ it('should preserve bound method reference', async () => {
132
+ const tagName = generateUniqueTagName('bind-test');
133
+
134
+ @customElementConfig({ tagName })
135
+ class BindTest extends CustomElement {
136
+ static style = ':host { display: block; }';
137
+
138
+ valueNum = 42;
139
+
140
+ @bind
141
+ getValue() {
142
+ return this.valueNum;
143
+ }
144
+
145
+ render() {
146
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
147
+ }
148
+ }
149
+
150
+ const el = document.createElement(tagName) as BindTest;
151
+ document.body.appendChild(el);
152
+ await waitForRender(el);
153
+
154
+ const bound1 = el.getValue;
155
+ const bound2 = el.getValue;
156
+
157
+ // Should return the same bound function
158
+ expect(bound1).toBe(bound2);
159
+ expect(bound1()).toBe(42);
160
+
161
+ document.body.removeChild(el);
162
+ });
163
+ });
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Tests for @property decorator
3
+ * Covers type conversion, attribute reflection, custom converters, and lifecycle integration
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import '../test-setup.js';
8
+ import { property } from './property.js';
9
+ import { CustomElement } from '../custom-element.js';
10
+ import { customElementConfig } from './custom-element-config.js';
11
+ import { generateUniqueTagName } from '../test-setup.js';
12
+ import { waitForRender } from '../test-utils.js';
13
+
14
+ describe('@property decorator', () => {
15
+ describe('Type: String', () => {
16
+ it('should convert attribute to string property', async () => {
17
+ const tagName = generateUniqueTagName('prop-string');
18
+
19
+ @customElementConfig({ tagName })
20
+ class StringTest extends CustomElement {
21
+ static style = ':host { display: block; }';
22
+ @property({ type: String })
23
+ text = 'default';
24
+
25
+ render() {
26
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
27
+ }
28
+ }
29
+
30
+ const el = document.createElement(tagName) as StringTest;
31
+ el.setAttribute('text', 'hello');
32
+ document.body.appendChild(el);
33
+ await waitForRender(el);
34
+
35
+ expect(el.text).toBe('hello');
36
+
37
+ document.body.removeChild(el);
38
+ });
39
+
40
+ it('should set property from JavaScript', async () => {
41
+ const tagName = generateUniqueTagName('prop-string');
42
+
43
+ @customElementConfig({ tagName })
44
+ class StringTest extends CustomElement {
45
+ static style = ':host { display: block; }';
46
+ @property({ type: String })
47
+ text = 'default';
48
+
49
+ render() {
50
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
51
+ }
52
+ }
53
+
54
+ const el = document.createElement(tagName) as StringTest;
55
+ document.body.appendChild(el);
56
+
57
+ el.text = 'updated';
58
+ await waitForRender(el);
59
+
60
+ expect(el.text).toBe('updated');
61
+
62
+ document.body.removeChild(el);
63
+ });
64
+ });
65
+
66
+ describe('Type: Number', () => {
67
+ it('should convert attribute to number property', async () => {
68
+ const tagName = generateUniqueTagName('prop-number');
69
+
70
+ @customElementConfig({ tagName })
71
+ class NumberTest extends CustomElement {
72
+ static style = ':host { display: block; }';
73
+ @property({ type: Number })
74
+ count = 0;
75
+
76
+ render() {
77
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
78
+ }
79
+ }
80
+
81
+ const el = document.createElement(tagName) as NumberTest;
82
+ el.setAttribute('count', '42');
83
+ document.body.appendChild(el);
84
+ await waitForRender(el);
85
+
86
+ expect(el.count).toBe(42);
87
+
88
+ document.body.removeChild(el);
89
+ });
90
+ });
91
+
92
+ describe('Type: Boolean', () => {
93
+ it('should convert presence of attribute to true', async () => {
94
+ const tagName = generateUniqueTagName('prop-bool');
95
+
96
+ @customElementConfig({ tagName })
97
+ class BooleanTest extends CustomElement {
98
+ static style = ':host { display: block; }';
99
+ @property({ type: Boolean })
100
+ active = false;
101
+
102
+ render() {
103
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
104
+ }
105
+ }
106
+
107
+ const el = document.createElement(tagName) as BooleanTest;
108
+ el.setAttribute('active', '');
109
+ document.body.appendChild(el);
110
+ await waitForRender(el);
111
+
112
+ expect(el.active).toBe(true);
113
+
114
+ document.body.removeChild(el);
115
+ });
116
+ });
117
+
118
+ describe('Attribute reflection (reflect: true)', () => {
119
+ it('should reflect string property to attribute', async () => {
120
+ const tagName = generateUniqueTagName('prop-reflect');
121
+
122
+ @customElementConfig({ tagName })
123
+ class ReflectTest extends CustomElement {
124
+ static style = ':host { display: block; }';
125
+ @property({ type: String, reflect: true })
126
+ status = 'pending';
127
+
128
+ render() {
129
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
130
+ }
131
+ }
132
+
133
+ const el = document.createElement(tagName) as ReflectTest;
134
+ document.body.appendChild(el);
135
+
136
+ el.status = 'complete';
137
+ await waitForRender(el);
138
+
139
+ expect(el.getAttribute('status')).toBe('complete');
140
+
141
+ document.body.removeChild(el);
142
+ });
143
+
144
+ it('should reflect boolean property to attribute', async () => {
145
+ const tagName = generateUniqueTagName('prop-reflect');
146
+
147
+ @customElementConfig({ tagName })
148
+ class ReflectTest extends CustomElement {
149
+ static style = ':host { display: block; }';
150
+ @property({ type: Boolean, reflect: true })
151
+ active = false;
152
+
153
+ render() {
154
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
155
+ }
156
+ }
157
+
158
+ const el = document.createElement(tagName) as ReflectTest;
159
+ document.body.appendChild(el);
160
+
161
+ el.active = true;
162
+ await waitForRender(el);
163
+ expect(el.hasAttribute('active')).toBe(true);
164
+
165
+ el.active = false;
166
+ await waitForRender(el);
167
+ expect(el.hasAttribute('active')).toBe(false);
168
+
169
+ document.body.removeChild(el);
170
+ });
171
+
172
+ it('should reflect number property to attribute', async () => {
173
+ const tagName = generateUniqueTagName('prop-reflect');
174
+
175
+ @customElementConfig({ tagName })
176
+ class ReflectTest extends CustomElement {
177
+ static style = ':host { display: block; }';
178
+ @property({ type: Number, reflect: true })
179
+ count = 0;
180
+
181
+ render() {
182
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
183
+ }
184
+ }
185
+
186
+ const el = document.createElement(tagName) as ReflectTest;
187
+ document.body.appendChild(el);
188
+
189
+ el.count = 42;
190
+ await waitForRender(el);
191
+
192
+ expect(el.getAttribute('count')).toBe('42');
193
+
194
+ document.body.removeChild(el);
195
+ });
196
+ });
197
+
198
+ describe('Property without type conversion', () => {
199
+ it('should handle object properties', async () => {
200
+ const tagName = generateUniqueTagName('prop-object');
201
+
202
+ @customElementConfig({ tagName })
203
+ class ObjectTest extends CustomElement {
204
+ static style = ':host { display: block; }';
205
+ @property()
206
+ data: any = null;
207
+
208
+ render() {
209
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
210
+ }
211
+ }
212
+
213
+ const el = document.createElement(tagName) as ObjectTest;
214
+ document.body.appendChild(el);
215
+
216
+ const testData = { foo: 'bar', num: 123 };
217
+ el.data = testData;
218
+ await waitForRender(el);
219
+
220
+ expect(el.data).toBe(testData);
221
+ expect(el.data.foo).toBe('bar');
222
+
223
+ document.body.removeChild(el);
224
+ });
225
+
226
+ it('should handle array properties', async () => {
227
+ const tagName = generateUniqueTagName('prop-array');
228
+
229
+ @customElementConfig({ tagName })
230
+ class ArrayTest extends CustomElement {
231
+ static style = ':host { display: block; }';
232
+ @property()
233
+ items: string[] = [];
234
+
235
+ render() {
236
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
237
+ }
238
+ }
239
+
240
+ const el = document.createElement(tagName) as ArrayTest;
241
+ document.body.appendChild(el);
242
+
243
+ el.items = ['a', 'b', 'c'];
244
+ await waitForRender(el);
245
+
246
+ expect(el.items.length).toBe(3);
247
+ expect(el.items[0]).toBe('a');
248
+
249
+ document.body.removeChild(el);
250
+ });
251
+ });
252
+
253
+ describe('Custom attribute names', () => {
254
+ it('should support custom attribute names', async () => {
255
+ const tagName = generateUniqueTagName('prop-custom');
256
+
257
+ @customElementConfig({ tagName })
258
+ class CustomAttrTest extends CustomElement {
259
+ static style = ':host { display: block; }';
260
+ @property({ type: String, attribute: 'data-value' })
261
+ value = '';
262
+
263
+ render() {
264
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
265
+ }
266
+ }
267
+
268
+ const el = document.createElement(tagName) as CustomAttrTest;
269
+ document.body.appendChild(el);
270
+
271
+ el.setAttribute('data-value', 'test');
272
+ await new Promise(resolve => setTimeout(resolve, 10));
273
+
274
+ expect(el.value).toBe('test');
275
+
276
+ document.body.removeChild(el);
277
+ });
278
+ });
279
+ });