@sprlab/wccompiler 0.10.12 → 0.11.1

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 @@
6
6
  * - WccSlotsDirective: Main directive activated via [wccSlots] attribute
7
7
  * - WccEvent: Single-event directive (wccEvent="name" + wccEmit output)
8
8
  * - WccEvents: Multi-event bridging directive (kebab-case → camelCase)
9
+ * - WccModel: Two-way binding bridge for [(prop)] banana-box syntax
9
10
  * - SlotContext: Interface for template context typing
10
11
  *
11
12
  * Usage:
@@ -547,3 +548,83 @@ export class WccEvents implements OnInit, OnDestroy {
547
548
  this.listeners = [];
548
549
  }
549
550
  }
551
+
552
+
553
+ // ─── WccModel — Two-way Binding Bridge (OPTIONAL) ───────────────────────────
554
+
555
+ /**
556
+ * Optional directive for Angular's [(prop)] banana-box syntax on WCC elements.
557
+ *
558
+ * NOTE: As of WCC v0.11+, the compiled component emits `propChange` directly,
559
+ * so [(prop)] works zero-config without this directive. This directive is kept
560
+ * as an alternative that uses the structured wcc:model event instead.
561
+ *
562
+ * Angular's [(prop)] expands to:
563
+ * [prop]="value" (propChange)="value = $event.detail"
564
+ *
565
+ * The component already emits `propChange` natively, so this works out of the box.
566
+ * This directive provides an alternative path via wcc:model for advanced use cases
567
+ * (e.g., when you need access to oldValue or want to handle multiple models centrally).
568
+ *
569
+ * Usage (optional):
570
+ * <wcc-input wccModel [(value)]="text"></wcc-input>
571
+ */
572
+ @Directive({
573
+ selector: '[wccModel]',
574
+ standalone: true,
575
+ })
576
+ export class WccModel implements OnInit, OnDestroy {
577
+ /** Optional explicit list of model prop names to bridge */
578
+ @Input() wccModel: string[] | '' = '';
579
+
580
+ private el = inject<ElementRef<HTMLElement>>(ElementRef);
581
+ private listener: ((e: Event) => void) | null = null;
582
+
583
+ ngOnInit(): void {
584
+ const hostEl = this.el.nativeElement;
585
+ const tagName = hostEl.tagName.toLowerCase();
586
+ if (!tagName.includes('-')) return;
587
+
588
+ this.setupModelBridge(hostEl, tagName);
589
+ }
590
+
591
+ private async setupModelBridge(hostEl: HTMLElement, tagName: string): Promise<void> {
592
+ // Determine which model props to bridge
593
+ let modelNames: string[];
594
+
595
+ if (Array.isArray(this.wccModel) && this.wccModel.length > 0) {
596
+ modelNames = this.wccModel;
597
+ } else {
598
+ // Auto-discover from component metadata
599
+ await customElements.whenDefined(tagName);
600
+ const ctor = customElements.get(tagName) as any;
601
+ modelNames = ctor?.__meta?.models || [];
602
+ }
603
+
604
+ if (modelNames.length === 0) return;
605
+
606
+ const modelSet = new Set(modelNames);
607
+
608
+ // Listen for wcc:model and re-dispatch as propChange
609
+ this.listener = (e: Event) => {
610
+ const detail = (e as CustomEvent).detail;
611
+ if (!detail || !modelSet.has(detail.prop)) return;
612
+
613
+ // Dispatch propChange (Angular banana-box convention)
614
+ hostEl.dispatchEvent(new CustomEvent(`${detail.prop}Change`, {
615
+ detail: detail.value,
616
+ bubbles: false,
617
+ cancelable: false,
618
+ }));
619
+ };
620
+
621
+ hostEl.addEventListener('wcc:model', this.listener);
622
+ }
623
+
624
+ ngOnDestroy(): void {
625
+ if (this.listener) {
626
+ this.el.nativeElement.removeEventListener('wcc:model', this.listener);
627
+ this.listener = null;
628
+ }
629
+ }
630
+ }
package/adapters/vue.js CHANGED
@@ -20,11 +20,11 @@
20
20
  */
21
21
 
22
22
  // ── Vue directive: v-wcc-model ──────────────────────────────────────
23
- // Fallback for non-Vite setups. Listens for propName-changed events directly.
23
+ // Fallback for non-Vite setups. Listens for wcc:model and filters by prop name.
24
24
 
25
25
  /**
26
26
  * Vue custom directive for two-way binding with WCC defineModel props.
27
- * Listens for `propName-changed` CustomEvent (emitted by WCC _modelSet).
27
+ * Listens for `wcc:model` CustomEvent and filters by prop name.
28
28
  */
29
29
  export const vWccModel = {
30
30
  mounted(el, binding) {
@@ -39,9 +39,10 @@ export const vWccModel = {
39
39
  el.setAttribute(propName, String(binding.value));
40
40
  }
41
41
 
42
- // Listen for propName-changed (WCC → Vue)
43
- const kebabName = propName.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
42
+ // Listen for wcc:model (WCC → Vue), filter by prop name
44
43
  const handler = (e) => {
44
+ if (e.detail?.prop !== propName) return;
45
+ const newVal = e.detail.value;
45
46
  // Try to update the Vue ref via setupState
46
47
  const instance = binding.instance;
47
48
  if (instance) {
@@ -51,9 +52,9 @@ export const vWccModel = {
51
52
  const val = setupState[key];
52
53
  if (val === binding.value || val?.value === binding.value) {
53
54
  if (val?.value !== undefined) {
54
- val.value = e.detail;
55
+ val.value = newVal;
55
56
  } else {
56
- setupState[key] = e.detail;
57
+ setupState[key] = newVal;
57
58
  }
58
59
  break;
59
60
  }
@@ -61,9 +62,9 @@ export const vWccModel = {
61
62
  }
62
63
  };
63
64
 
64
- el.addEventListener(`${kebabName}-changed`, handler);
65
+ el.addEventListener('wcc:model', handler);
65
66
  el.__wccModelHandlers = el.__wccModelHandlers || {};
66
- el.__wccModelHandlers[propName] = { handler, eventName: `${kebabName}-changed` };
67
+ el.__wccModelHandlers[propName] = { handler, eventName: 'wcc:model' };
67
68
  },
68
69
 
69
70
  updated(el, binding) {
@@ -44,12 +44,12 @@
44
44
  * ```
45
45
  *
46
46
  * @example Two-way binding with [(prop)]
47
- * The adapter translates wcc:model events to Angular's propChange convention.
47
+ * The WccModel directive translates wcc:model events to Angular's propChange convention.
48
48
  * Angular's banana-in-a-box [(prop)] expands to:
49
49
  * [prop]="value" (propChange)="value = $event.detail"
50
50
  *
51
51
  * So when the WCC component emits wcc:model with { prop: 'value', value: 'new' },
52
- * the adapter re-dispatches as 'valueChange' CustomEvent, which Angular picks up.
52
+ * the WccModel directive re-dispatches as 'valueChange' CustomEvent, which Angular picks up.
53
53
  *
54
54
  * @example ngModel support (requires ControlValueAccessor)
55
55
  * For ngModel/ReactiveForms, see the WccValueAccessor guide in:
@@ -65,11 +65,9 @@ export function wccVuePlugin(options = {}) {
65
65
 
66
66
  let result = code
67
67
 
68
- // NOTE: As of WCC 0.10.3+, basic events work WITHOUT this plugin because
69
- // the compiled component emits events in multiple formats (kebab, camelCase, lowercase).
70
- // However, v-model:propName STILL REQUIRES this plugin because Vue assigns the raw
71
- // Event object to the ref — it doesn't extract .detail automatically.
72
- // This transform rewrites v-model:prop to @prop-changed="ref = $event.detail".
68
+ // NOTE: The compiled WCC component emits only `wcc:model` as its single
69
+ // canonical model change event. Framework-specific event formats are handled
70
+ // by each framework's adapter/plugin.
73
71
  //
74
72
  // This plugin is needed for:
75
73
  // 1. v-model:propName (Vue can't unwrap CustomEvent.detail natively)
@@ -78,12 +76,14 @@ export function wccVuePlugin(options = {}) {
78
76
 
79
77
  // Transform v-model:propName="expr" on custom elements (tags with hyphens)
80
78
  // Also handles modifiers: v-model:propName.trim.number="expr"
81
- // → :propName="expr" @propName-changed="expr = $event.detail"
82
- // with modifiers applied to the event handler value:
83
- // .trim → $event.detail.trim() (for string values)
84
- // .number → Number($event.detail)
85
- // .lazy → uses @propName-changed (same event, no difference for CE)
86
- // Run in a loop to handle multiple v-model on the same element
79
+ // → :propName="expr" + merged @wcc:model handler
80
+ // with modifiers applied to the extracted value:
81
+ // .trim → value.trim() (for string values)
82
+ // .number → Number(value)
83
+ // .lazy → no-op for custom elements
84
+ //
85
+ // Multiple v-model:prop on the same element are merged into a single
86
+ // @wcc:model handler with semicolons (avoids Vue "Duplicate attribute" error).
87
87
  let prev = ''
88
88
  while (prev !== result) {
89
89
  prev = result
@@ -91,7 +91,7 @@ export function wccVuePlugin(options = {}) {
91
91
  /(<[\w]+-[\w-]*(?:\s[^>]*?)?)\bv-model:(\w+)((?:\.\w+)*)="([^"]+)"/,
92
92
  (match, prefix, prop, modifiersStr, expr) => {
93
93
  const modifiers = modifiersStr ? modifiersStr.slice(1).split('.') : []
94
- let value = '$event.detail'
94
+ let value = '$event.detail.value'
95
95
  // Apply modifiers in order
96
96
  for (const mod of modifiers) {
97
97
  if (mod === 'trim') {
@@ -101,14 +101,23 @@ export function wccVuePlugin(options = {}) {
101
101
  }
102
102
  // .lazy is a no-op for custom elements (they already use change events)
103
103
  }
104
- return `${prefix}:${prop}="${expr}" @${prop}-changed="${expr} = ${value}"`
104
+ const handler = `$event.detail.prop === '${prop}' && (${expr} = ${value})`
105
+ // Check if there's already a @wcc:model on this element — append to it
106
+ if (prefix.includes('@wcc:model="')) {
107
+ const merged = prefix.replace(
108
+ /@wcc:model="([^"]*)"/,
109
+ (_, existing) => `@wcc:model="${existing}; ${handler}"`
110
+ )
111
+ return `${merged}:${prop}="${expr}"`
112
+ }
113
+ return `${prefix}:${prop}="${expr}" @wcc:model="${handler}"`
105
114
  }
106
115
  )
107
116
  }
108
117
 
109
118
  // Transform v-model="expr" (without argument) on custom elements
110
119
  // Also handles modifiers: v-model.trim.lazy="expr"
111
- // → :model-value="expr" @model-value-changed="expr = $event.detail"
120
+ // → :model-value="expr" + merged @wcc:model handler
112
121
  prev = ''
113
122
  while (prev !== result) {
114
123
  prev = result
@@ -116,7 +125,7 @@ export function wccVuePlugin(options = {}) {
116
125
  /(<[\w]+-[\w-]*(?:\s[^>]*?)?)\bv-model((?:\.\w+)*)="([^"]+)"/,
117
126
  (match, prefix, modifiersStr, expr) => {
118
127
  const modifiers = modifiersStr ? modifiersStr.slice(1).split('.') : []
119
- let value = '$event.detail'
128
+ let value = '$event.detail.value'
120
129
  for (const mod of modifiers) {
121
130
  if (mod === 'trim') {
122
131
  value = `(typeof ${value} === 'string' ? (${value}).trim() : ${value})`
@@ -124,11 +133,29 @@ export function wccVuePlugin(options = {}) {
124
133
  value = `Number(${value})`
125
134
  }
126
135
  }
127
- return `${prefix}:model-value="${expr}" @model-value-changed="${expr} = ${value}"`
136
+ const handler = `$event.detail.prop === 'modelValue' && (${expr} = ${value})`
137
+ // Check if there's already a @wcc:model on this element — append to it
138
+ if (prefix.includes('@wcc:model="')) {
139
+ const merged = prefix.replace(
140
+ /@wcc:model="([^"]*)"/,
141
+ (_, existing) => `@wcc:model="${existing}; ${handler}"`
142
+ )
143
+ return `${merged}:model-value="${expr}"`
144
+ }
145
+ return `${prefix}:model-value="${expr}" @wcc:model="${handler}"`
128
146
  }
129
147
  )
130
148
  }
131
149
 
150
+ // Post-process: merge any duplicate @wcc:model attributes on the same element
151
+ // This handles the case where v-model (no arg) was before v-model:prop in source order
152
+ result = result.replace(
153
+ /<([\w]+-[\w-]*)((?:\s[^>]*?)?)@wcc:model="([^"]*)"((?:\s[^>]*?)?)@wcc:model="([^"]*)"([^>]*?)>/g,
154
+ (match, tag, before, handler1, middle, handler2, after) => {
155
+ return `<${tag}${before}@wcc:model="${handler1}; ${handler2}"${middle}${after}>`
156
+ }
157
+ )
158
+
132
159
  // ── Slot transforms ──
133
160
  // Transform <template #name>content</template> inside custom elements
134
161
  // → <div slot="name">content</div>
package/lib/codegen.js CHANGED
@@ -1773,43 +1773,23 @@ export function generateComponent(parseResult, options = {}) {
1773
1773
  }
1774
1774
 
1775
1775
  // _emit method (if emits declared)
1776
- // Emits the event in multiple formats for cross-framework compatibility:
1777
- // 1. Original name (kebab-case) for direct addEventListener and Angular (event-name)
1778
- // 2. camelCase — for Angular WccEvents directive
1779
- // 3. lowercase — for React 19 (onEventName → addEventListener('eventname'))
1776
+ // Emits the original event name + lowercase-no-hyphens for React 19 compatibility.
1777
+ // React 19 maps `oncountchanged` addEventListener('countchanged').
1780
1778
  if (emits.length > 0) {
1781
1779
  lines.push(' _emit(name, detail) {');
1782
1780
  lines.push(' const evt = { detail, bubbles: true, composed: true };');
1783
1781
  lines.push(' this.dispatchEvent(new CustomEvent(name, evt));');
1784
- // camelCase version (only if name contains hyphens)
1785
- lines.push(" if (name.includes('-')) {");
1786
- lines.push(" const camel = name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());");
1787
- lines.push(' this.dispatchEvent(new CustomEvent(camel, evt));');
1788
- // lowercase version for React 19
1789
- lines.push(' this.dispatchEvent(new CustomEvent(camel.toLowerCase(), evt));');
1790
- lines.push(' } else {');
1791
- // If no hyphens, just emit lowercase (for React 19)
1792
- lines.push(' const lower = name.toLowerCase();');
1793
- lines.push(' if (lower !== name) this.dispatchEvent(new CustomEvent(lower, evt));');
1794
- lines.push(' }');
1782
+ lines.push(" const lower = name.replace(/-/g, '').toLowerCase();");
1783
+ lines.push(' if (lower !== name) this.dispatchEvent(new CustomEvent(lower, evt));');
1795
1784
  lines.push(' }');
1796
1785
  lines.push('');
1797
1786
  }
1798
1787
 
1799
1788
  // _modelSet methods (one per defineModel prop — emits events on internal write)
1800
1789
  // Emits:
1801
- // 1. wcc:model — generic event for vanilla JS and WCC-to-WCC binding
1802
- // 2. propName-changedkebab-case for direct addEventListener and Vue plugin
1803
- // 3. propNameChanged — camelCase for Angular WccEvents / direct binding
1804
- // 4. propnamechanged — lowercase for React 19 (onPropnameChanged → 'propnamechanged')
1805
- // 5. propNameChange — for Angular [(prop)] banana-box syntax
1806
- //
1807
- // NOTE: Vue v-model:prop requires the wccVuePlugin because Vue assigns the raw
1808
- // Event object to the ref (not event.detail). The plugin transforms v-model:prop
1809
- // to @prop-changed="ref = $event.detail" which correctly extracts the value.
1790
+ // 1. wcc:model — canonical event for vanilla JS, WCC-to-WCC, React adapter, Vue plugin
1791
+ // 2. propNameChange — for Angular [(prop)] banana-box syntax (zero-config)
1810
1792
  for (const md of modelDefs) {
1811
- const kebabName = camelToKebab(md.name);
1812
- const camelChanged = `${md.name}Changed`;
1813
1793
  lines.push(` _modelSet_${md.name}(newVal) {`);
1814
1794
  lines.push(` const oldVal = this._m_${md.name}();`);
1815
1795
  lines.push(` this._m_${md.name}(newVal);`);
@@ -1818,13 +1798,6 @@ export function generateComponent(parseResult, options = {}) {
1818
1798
  lines.push(` bubbles: true,`);
1819
1799
  lines.push(` composed: true`);
1820
1800
  lines.push(` }));`);
1821
- // Kebab-case: prop-name-changed (Vue plugin, addEventListener)
1822
- lines.push(` this.dispatchEvent(new CustomEvent('${kebabName}-changed', { detail: newVal, bubbles: true }));`);
1823
- // camelCase: propNameChanged (Angular, addEventListener)
1824
- lines.push(` this.dispatchEvent(new CustomEvent('${camelChanged}', { detail: newVal, bubbles: true }));`);
1825
- // lowercase: propnamechanged (React 19)
1826
- lines.push(` this.dispatchEvent(new CustomEvent('${camelChanged.toLowerCase()}', { detail: newVal, bubbles: true }));`);
1827
- // Angular banana-box: propNameChange
1828
1801
  lines.push(` this.dispatchEvent(new CustomEvent('${md.name}Change', { detail: newVal, bubbles: true }));`);
1829
1802
  lines.push(' }');
1830
1803
  lines.push('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.10.12",
3
+ "version": "0.11.1",
4
4
  "description": "Zero-runtime compiler that transforms .wcc single-file components into native web components with signals-based reactivity",
5
5
  "type": "module",
6
6
  "exports": {