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.
@@ -63,6 +63,8 @@ export interface PropertyOptions {
63
63
  */
64
64
  reflect?: boolean;
65
65
 
66
+ readonly?: boolean;
67
+
66
68
  /**
67
69
  * Custom converter for complex attribute/property transformations.
68
70
  * @type {AttributeConverter<any>}
@@ -76,7 +78,7 @@ export interface PropertyOptions {
76
78
  *
77
79
  * @typedef {PropertyOptions & { name: string }} PropertyOptionsWithName
78
80
  */
79
- export type PropertyOptionsWithName = PropertyOptions & {name: string};
81
+ export type PropertyOptionsWithName = PropertyOptions & { name: string };
80
82
 
81
83
  /**
82
84
  * Track which elements are currently updating attributes from property setters.
@@ -96,7 +98,7 @@ const settingAttributes = new WeakSet<HTMLElement>();
96
98
  */
97
99
  const pendingAttributeReflections = new WeakMap<
98
100
  HTMLElement,
99
- Map<string, {value: any; type: any; converter?: AttributeConverter<any>}>
101
+ Map<string, { value: any; type: any; converter?: AttributeConverter<any> }>
100
102
  >();
101
103
 
102
104
  /**
@@ -109,7 +111,7 @@ const pendingAttributeReflections = new WeakMap<
109
111
  */
110
112
  const pendingUpdates = new WeakMap<
111
113
  HTMLElement,
112
- Array<{propertyKey: string; oldValue: any; newValue: any}>
114
+ Array<{ propertyKey: string; oldValue: any; newValue: any }>
113
115
  >();
114
116
 
115
117
  /**
@@ -252,10 +254,10 @@ function normalizeType(
252
254
  case "array":
253
255
  return Array;
254
256
  default:
255
- return String;
257
+ return null;
256
258
  }
257
259
  }
258
- return type || String;
260
+ return type;
259
261
  }
260
262
 
261
263
  /**
@@ -397,6 +399,19 @@ function updateAttribute(
397
399
  }
398
400
  }
399
401
 
402
+ const alreadLogedNodeNames = new Set<string>();
403
+
404
+ function logConnectedCallback(nodeName: string) {
405
+ if (!alreadLogedNodeNames.has(nodeName)) {
406
+ alreadLogedNodeNames.add(nodeName);
407
+ if (typeof console !== "undefined" && console.info) {
408
+ console.info(
409
+ `[CustomElement] connectedCallback called for <${nodeName.toLocaleLowerCase()}>`,
410
+ );
411
+ }
412
+ }
413
+ }
414
+
400
415
  /**
401
416
  * Class field decorator that creates a reactive property with automatic attribute syncing.
402
417
  *
@@ -468,7 +483,13 @@ function updateAttribute(
468
483
  export function property(
469
484
  options: PropertyOptions = {},
470
485
  ): (target: any, propertyKey: string) => void {
471
- const {type: rawType, attribute = true, reflect = false, converter} = options;
486
+ const {
487
+ type: rawType,
488
+ attribute = true,
489
+ reflect = false,
490
+ converter,
491
+ readonly = false,
492
+ } = options;
472
493
 
473
494
  const type = normalizeType(rawType);
474
495
 
@@ -477,6 +498,132 @@ export function property(
477
498
  const instanceMap = new WeakMap<any, any>();
478
499
  const attributeName = getAttributeName(propertyKey, attribute);
479
500
 
501
+ // --- Patch lifecycle methods ONLY if defined in derived class ---
502
+ // Only patch once per class
503
+ if (!target.__p_elements_core_lifecycle_patch_applied) {
504
+ // Patch connectedCallback
505
+ if (
506
+ Object.prototype.hasOwnProperty.call(target, "connectedCallback") &&
507
+ typeof target.connectedCallback === "function"
508
+ ) {
509
+ const originalConnected = target.connectedCallback;
510
+ target.connectedCallback = function patchedConnectedCallback(
511
+ ...args: any[]
512
+ ) {
513
+ const PATCHED_FLAG = "__p_elements_core_pending_updates_called";
514
+ if (!this[PATCHED_FLAG]) {
515
+ let superCalled = false;
516
+ const origProcess = processPendingUpdates;
517
+ const self = this;
518
+ function wrappedProcessPendingUpdates(element: any) {
519
+ if (element === self) {
520
+ superCalled = true;
521
+ }
522
+ return origProcess(element);
523
+ }
524
+ (globalThis as any).__origProcessPendingUpdates =
525
+ processPendingUpdates;
526
+ (globalThis as any).processPendingUpdates =
527
+ wrappedProcessPendingUpdates;
528
+ try {
529
+ if (originalConnected) {
530
+ originalConnected.apply(this, args);
531
+ }
532
+ } finally {
533
+ (globalThis as any).processPendingUpdates = (
534
+ globalThis as any
535
+ ).__origProcessPendingUpdates;
536
+ delete (globalThis as any).__origProcessPendingUpdates;
537
+ }
538
+ if (!superCalled) {
539
+ let baseProto = Object.getPrototypeOf(target);
540
+ let baseConnected = baseProto && baseProto.connectedCallback;
541
+ if (typeof baseConnected === "function") {
542
+ baseConnected.apply(this, args);
543
+ if (typeof console !== "undefined" && console.info) {
544
+ // log this once per nodeName
545
+ logConnectedCallback(this.nodeName);
546
+ }
547
+ } else {
548
+ processPendingUpdates(this);
549
+ if (typeof console !== "undefined" && console.info) {
550
+ console.info(
551
+ `[p-elements-core] called processPendingUpdates automatically for <${this.nodeName}>`,
552
+ );
553
+ }
554
+ }
555
+ }
556
+ this[PATCHED_FLAG] = true;
557
+ } else if (originalConnected) {
558
+ originalConnected.apply(this, args);
559
+ }
560
+ };
561
+ }
562
+
563
+ // Patch disconnectedCallback
564
+ if (
565
+ Object.prototype.hasOwnProperty.call(target, "disconnectedCallback") &&
566
+ typeof target.disconnectedCallback === "function"
567
+ ) {
568
+ const originalDisconnected = target.disconnectedCallback;
569
+ target.disconnectedCallback = function patchedDisconnectedCallback(
570
+ ...args: any[]
571
+ ) {
572
+ const PATCHED_FLAG = "__p_elements_core_disconnected_called";
573
+ if (!this[PATCHED_FLAG]) {
574
+ let superCalled = false;
575
+ const baseProto = Object.getPrototypeOf(target);
576
+ const baseDisconnected =
577
+ baseProto && baseProto.disconnectedCallback;
578
+ if (originalDisconnected) {
579
+ originalDisconnected.apply(this, args);
580
+ superCalled = true;
581
+ }
582
+ if (!superCalled && typeof baseDisconnected === "function") {
583
+ baseDisconnected.apply(this, args);
584
+ }
585
+ this[PATCHED_FLAG] = true;
586
+ } else if (originalDisconnected) {
587
+ originalDisconnected.apply(this, args);
588
+ }
589
+ };
590
+ }
591
+
592
+ // Patch attributeChangedCallback
593
+ if (
594
+ Object.prototype.hasOwnProperty.call(
595
+ target,
596
+ "attributeChangedCallback",
597
+ ) &&
598
+ typeof target.attributeChangedCallback === "function"
599
+ ) {
600
+ const originalAttributeChanged = target.attributeChangedCallback;
601
+ target.attributeChangedCallback =
602
+ function patchedAttributeChangedCallback(...args: any[]) {
603
+ const PATCHED_FLAG = "__p_elements_core_attribute_changed_called";
604
+
605
+ if (!this[PATCHED_FLAG]) {
606
+ let superCalled = false;
607
+ const baseProto = Object.getPrototypeOf(target);
608
+ const baseAttributeChanged =
609
+ baseProto && baseProto.attributeChangedCallback;
610
+ if (originalAttributeChanged) {
611
+ originalAttributeChanged.apply(this, args);
612
+ superCalled = true;
613
+ }
614
+ if (!superCalled && typeof baseAttributeChanged === "function") {
615
+ baseAttributeChanged.apply(this, args);
616
+ }
617
+ this[PATCHED_FLAG] = true;
618
+ } else if (originalAttributeChanged) {
619
+ originalAttributeChanged.apply(this, args);
620
+ }
621
+ };
622
+ }
623
+
624
+ target.__p_elements_core_lifecycle_patch_applied = true;
625
+ }
626
+
480
627
  Object.defineProperty(target, propertyKey, {
481
628
  get(this: any) {
482
629
  // On first access, check if an HTML attribute exists and use that instead
@@ -520,8 +667,18 @@ export function property(
520
667
  return undefined;
521
668
  },
522
669
  set(this: any, value: any) {
670
+ // If readonly is true and value already exists, prevent setting
671
+ if (readonly && instanceMap.has(this)) {
672
+ return;
673
+ }
674
+
523
675
  const oldValue = instanceMap.get(this);
524
676
  let convertedValue: any;
677
+ let isRenderOnSet = false;
678
+
679
+ if ((type === null || type === undefined) && value !== oldValue) {
680
+ isRenderOnSet = true;
681
+ }
525
682
 
526
683
  // On first set, check if an HTML attribute exists and use that instead of the default
527
684
  if (
@@ -545,7 +702,6 @@ export function property(
545
702
 
546
703
  // Trigger update for initial HTML attribute only if connected
547
704
  if (this.isConnected) {
548
-
549
705
  if (typeof this.renderNow === "function") {
550
706
  this.renderNow();
551
707
  }
@@ -561,14 +717,19 @@ export function property(
561
717
  }
562
718
  pendingUpdates
563
719
  .get(this)!
564
- .push({propertyKey, oldValue, newValue: convertedValue});
720
+ .push({ propertyKey, oldValue, newValue: convertedValue });
565
721
  }
566
722
  }
723
+
567
724
  return;
568
725
  }
569
726
  }
727
+ if (isRenderOnSet) {
728
+ convertedValue = value;
729
+ } else {
730
+ convertedValue = convertPropertyValue(value, type, converter);
731
+ }
570
732
 
571
- convertedValue = convertPropertyValue(value, type, converter);
572
733
  if (oldValue === convertedValue) {
573
734
  return;
574
735
  }
@@ -625,9 +786,14 @@ export function property(
625
786
  }
626
787
  pendingUpdates
627
788
  .get(this)!
628
- .push({propertyKey, oldValue, newValue: convertedValue});
789
+ .push({ propertyKey, oldValue, newValue: convertedValue });
629
790
  }
630
791
  }
792
+
793
+ // Call renderNow if @RenderOnSet is used and value changed
794
+ if (isRenderOnSet && typeof this.renderNow === "function") {
795
+ this.renderNow();
796
+ }
631
797
  },
632
798
  configurable: true,
633
799
  });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Tests for @query decorator
3
+ * Covers Shadow DOM and Light DOM queries
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import '../test-setup.js';
8
+ import { query } from './query.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('@query decorator', () => {
15
+ it('should query element in shadow DOM by default', async () => {
16
+ const tagName = generateUniqueTagName('query-shadow');
17
+
18
+ @customElementConfig({ tagName })
19
+ class QueryTest extends CustomElement {
20
+ static style = ':host { display: block; }';
21
+ @query('.test-target')
22
+ target: HTMLElement;
23
+
24
+ render() {
25
+ return {
26
+ vnodeSelector: 'div',
27
+ properties: {},
28
+ children: [
29
+ {
30
+ vnodeSelector: 'span',
31
+ properties: { class: 'test-target' },
32
+ text: 'Found me!',
33
+ domNode: null as any,
34
+ children: []
35
+ },
36
+ ],
37
+ text: undefined,
38
+ domNode: null
39
+ };
40
+ }
41
+ }
42
+
43
+ const el = document.createElement(tagName) as QueryTest;
44
+ document.body.appendChild(el);
45
+ await waitForRender(el);
46
+
47
+ expect(el.target).toBeDefined();
48
+ expect(el.target.tagName).toBe('SPAN');
49
+ expect(el.target.classList.contains('test-target')).toBe(true);
50
+
51
+ document.body.removeChild(el);
52
+ });
53
+
54
+ it('should query element in light DOM when useShadowRoot is false', async () => {
55
+ const tagName = generateUniqueTagName('query-light');
56
+
57
+ @customElementConfig({ tagName })
58
+ class QueryTest extends CustomElement {
59
+ static style = ':host { display: block; }';
60
+ @query('.light-target', false)
61
+ target: HTMLElement;
62
+
63
+ render() {
64
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
65
+ }
66
+ }
67
+
68
+ const el = document.createElement(tagName) as QueryTest;
69
+ const lightChild = document.createElement('div');
70
+ lightChild.className = 'light-target';
71
+ lightChild.textContent = 'Light DOM';
72
+ el.appendChild(lightChild);
73
+
74
+ document.body.appendChild(el);
75
+ await waitForRender(el);
76
+
77
+ expect(el.target).toBeDefined();
78
+ expect(el.target.className).toBe('light-target');
79
+
80
+ document.body.removeChild(el);
81
+ });
82
+
83
+ it('should return null when element not found', async () => {
84
+ const tagName = generateUniqueTagName('query-missing');
85
+
86
+ @customElementConfig({ tagName })
87
+ class QueryTest extends CustomElement {
88
+ static style = ':host { display: block; }';
89
+ @query('.does-not-exist')
90
+ target: HTMLElement;
91
+
92
+ render() {
93
+ return { vnodeSelector: 'div', properties: {}, children: [], text: undefined, domNode: null };
94
+ }
95
+ }
96
+
97
+ const el = document.createElement(tagName) as QueryTest;
98
+ document.body.appendChild(el);
99
+ await waitForRender(el);
100
+
101
+ expect(el.target).toBeNull();
102
+
103
+ document.body.removeChild(el);
104
+ });
105
+
106
+ it('should update query result when DOM changes', async () => {
107
+ const tagName = generateUniqueTagName('query-dynamic');
108
+ let showElement = false;
109
+
110
+ @customElementConfig({ tagName })
111
+ class QueryTest extends CustomElement {
112
+ static style = ':host { display: block; }';
113
+ @query('.dynamic')
114
+ target: HTMLElement;
115
+
116
+ render() {
117
+ const children = showElement
118
+ ? [{ vnodeSelector: 'span', properties: { class: 'dynamic' }, text: 'Dynamic', domNode: null as any, children: [] }]
119
+ : [];
120
+
121
+ return {
122
+ vnodeSelector: 'div',
123
+ properties: {},
124
+ children,
125
+ text: undefined,
126
+ domNode: null
127
+ };
128
+ }
129
+ }
130
+
131
+ const el = document.createElement(tagName) as QueryTest;
132
+ document.body.appendChild(el);
133
+ await waitForRender(el);
134
+
135
+ expect(el.target).toBeNull();
136
+
137
+ showElement = true;
138
+ el.renderNow();
139
+ await waitForRender(el);
140
+
141
+ expect(el.target).toBeDefined();
142
+ expect(el.target.tagName).toBe('SPAN');
143
+
144
+ document.body.removeChild(el);
145
+ });
146
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Tests for CSS helpers
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { cssApplyToCssVars, cssApplyVars, replaceApplyToCssVars } from './css.js';
7
+
8
+ describe('CSS helpers', () => {
9
+ describe('cssApplyToCssVars', () => {
10
+ it('should convert CSS @apply mixins to CSS variables', () => {
11
+ const inputCSS = `
12
+ --my-mixin: {
13
+ color: red;
14
+ font-size: 16px;
15
+ };
16
+ `;
17
+
18
+ const result = cssApplyToCssVars(inputCSS);
19
+
20
+ expect(result).toContain('--my-mixin_-_color: red');
21
+ expect(result).toContain('--my-mixin_-_font-size: 16px');
22
+ expect(cssApplyVars.has('--my-mixin')).toBe(true);
23
+ expect(cssApplyVars.get('--my-mixin')).toContain('color');
24
+ expect(cssApplyVars.get('--my-mixin')).toContain('font-size');
25
+ });
26
+
27
+ it('should return null if no mixins found', () => {
28
+ const inputCSS = `
29
+ .class {
30
+ color: blue;
31
+ padding: 10px;
32
+ }
33
+ `;
34
+
35
+ const result = cssApplyToCssVars(inputCSS);
36
+ expect(result).toBeNull();
37
+ });
38
+
39
+ it('should handle multiple mixins', () => {
40
+ const inputCSS = `
41
+ --mixin-one: {
42
+ color: red;
43
+ };
44
+ --mixin-two: {
45
+ background: blue;
46
+ margin: 5px;
47
+ };
48
+ `;
49
+
50
+ const result = cssApplyToCssVars(inputCSS);
51
+
52
+ expect(result).toContain('--mixin-one_-_color: red');
53
+ expect(result).toContain('--mixin-two_-_background: blue');
54
+ expect(result).toContain('--mixin-two_-_margin: 5px');
55
+ expect(cssApplyVars.has('--mixin-one')).toBe(true);
56
+ expect(cssApplyVars.has('--mixin-two')).toBe(true);
57
+ });
58
+
59
+ it('should preserve regular CSS variables', () => {
60
+ const inputCSS = `
61
+ --regular-var: 20px;
62
+ --my-mixin: {
63
+ padding: 10px;
64
+ };
65
+ `;
66
+
67
+ const result = cssApplyToCssVars(inputCSS);
68
+
69
+ expect(result).toContain('--regular-var: 20px');
70
+ expect(result).toContain('--my-mixin_-_padding: 10px');
71
+ });
72
+
73
+ it('should handle empty mixin values', () => {
74
+ const inputCSS = `
75
+ --empty-mixin: {
76
+ ;
77
+ };
78
+ `;
79
+
80
+ const result = cssApplyToCssVars(inputCSS);
81
+ expect(result).not.toBeNull();
82
+ });
83
+ });
84
+
85
+ describe('replaceApplyToCssVars', () => {
86
+ it('should replace @apply with CSS variable references', () => {
87
+ // First set up a mixin
88
+ cssApplyVars.set('--theme-colors', ['color', 'background']);
89
+
90
+ const inputCSS = `
91
+ .element {
92
+ @apply --theme-colors;
93
+ }
94
+ `;
95
+
96
+ const result = replaceApplyToCssVars(inputCSS);
97
+
98
+ expect(result).toContain('color: var(--theme-colors_-_color)');
99
+ expect(result).toContain('background: var(--theme-colors_-_background)');
100
+ expect(result).not.toContain('@apply');
101
+ });
102
+
103
+ it('should handle @apply with parentheses', () => {
104
+ cssApplyVars.set('--button-style', ['padding', 'border']);
105
+
106
+ const inputCSS = `
107
+ button {
108
+ @apply(--button-style);
109
+ }
110
+ `;
111
+
112
+ const result = replaceApplyToCssVars(inputCSS);
113
+
114
+ expect(result).toContain('padding: var(--button-style_-_padding)');
115
+ expect(result).toContain('border: var(--button-style_-_border)');
116
+ });
117
+
118
+ it('should leave CSS unchanged if mixin not defined', () => {
119
+ const inputCSS = `
120
+ .element {
121
+ @apply --undefined-mixin;
122
+ }
123
+ `;
124
+
125
+ const result = replaceApplyToCssVars(inputCSS);
126
+
127
+ // Should still contain @apply since mixin isn't defined
128
+ expect(result).toContain('@apply --undefined-mixin');
129
+ });
130
+
131
+ it('should handle multiple @apply calls', () => {
132
+ cssApplyVars.set('--mixin-a', ['color']);
133
+ cssApplyVars.set('--mixin-b', ['background']);
134
+
135
+ const inputCSS = `
136
+ .one {
137
+ @apply --mixin-a;
138
+ }
139
+ .two {
140
+ @apply --mixin-b;
141
+ }
142
+ `;
143
+
144
+ const result = replaceApplyToCssVars(inputCSS);
145
+
146
+ expect(result).toContain('color: var(--mixin-a_-_color)');
147
+ expect(result).toContain('background: var(--mixin-b_-_background)');
148
+ });
149
+ });
150
+ });