@sprlab/wccompiler 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/angular.js +70 -2
- package/adapters/angular.ts +310 -0
- package/adapters/react.js +103 -0
- package/integrations/react.js +13 -109
- package/lib/codegen.js +46 -4
- package/package.json +3 -2
package/adapters/angular.js
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Angular adapter for WCC defineModel
|
|
2
|
+
* Angular adapter for WCC (defineModel + Scoped Slots).
|
|
3
|
+
*
|
|
4
|
+
* This module provides Angular integration for WCC components:
|
|
5
|
+
*
|
|
6
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
7
|
+
* SCOPED SLOTS (Native Angular Syntax)
|
|
8
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
9
|
+
*
|
|
10
|
+
* For native Angular scoped slot support, use the TypeScript directives:
|
|
11
|
+
*
|
|
12
|
+
* import { WccSlotsDirective, WccSlotDef } from '@sprlab/wccompiler/adapters/angular';
|
|
13
|
+
*
|
|
14
|
+
* Setup:
|
|
15
|
+
* @Component({
|
|
16
|
+
* imports: [WccSlotsDirective, WccSlotDef],
|
|
17
|
+
* schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
18
|
+
* template: `...`
|
|
19
|
+
* })
|
|
20
|
+
*
|
|
21
|
+
* The directives auto-activate on custom elements (tags with hyphen).
|
|
22
|
+
* No [wccSlots] attribute is needed on the host element.
|
|
23
|
+
*
|
|
24
|
+
* Slot declaration uses ng-template[slot] syntax:
|
|
25
|
+
*
|
|
26
|
+
* <!-- Named slot (static content, no let-*) -->
|
|
27
|
+
* <wcc-card>
|
|
28
|
+
* <ng-template slot="header"><strong>My Header</strong></ng-template>
|
|
29
|
+
* </wcc-card>
|
|
30
|
+
*
|
|
31
|
+
* <!-- Scoped slot (reactive data via let-*) -->
|
|
32
|
+
* <wcc-card>
|
|
33
|
+
* <ng-template slot="stats" let-likes>{{ likes }} likes</ng-template>
|
|
34
|
+
* </wcc-card>
|
|
35
|
+
*
|
|
36
|
+
* <!-- Multiple props in scoped slot -->
|
|
37
|
+
* <wcc-card>
|
|
38
|
+
* <ng-template slot="details" let-data let-likes="likes" let-total="total">
|
|
39
|
+
* {{ likes }}/{{ total }}
|
|
40
|
+
* </ng-template>
|
|
41
|
+
* </wcc-card>
|
|
42
|
+
*
|
|
43
|
+
* How it works:
|
|
44
|
+
* - WccSlotDef captures the TemplateRef and slot name from the 'slot' attribute
|
|
45
|
+
* - WccSlotsDirective classifies slots as named or scoped using __scopedSlots
|
|
46
|
+
* - Named slots render immediately into <div slot="name" style="display:contents">
|
|
47
|
+
* - Scoped slots register a renderer via element.registerSlotRenderer()
|
|
48
|
+
* - When slot props change, the renderer updates the Angular EmbeddedView
|
|
49
|
+
* - Compatible with OnPush change detection strategy
|
|
50
|
+
*
|
|
51
|
+
* The directive source is in adapters/angular.ts (TypeScript with Angular decorators).
|
|
52
|
+
*
|
|
53
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
54
|
+
* BACKWARD COMPATIBILITY: slot-template-* (Token Replacement)
|
|
55
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
56
|
+
*
|
|
57
|
+
* The legacy slot-template-* attribute approach continues to work without
|
|
58
|
+
* importing the directives:
|
|
59
|
+
*
|
|
60
|
+
* <wcc-list>
|
|
61
|
+
* <div slot-template-item="<li>{%item%}</li>"></div>
|
|
62
|
+
* </wcc-list>
|
|
63
|
+
*
|
|
64
|
+
* When WccSlotsDirective IS imported, ng-template[slot] takes priority over
|
|
65
|
+
* slot-template-* for the same slot name. Slots not covered by ng-template
|
|
66
|
+
* continue using the token replacement path.
|
|
67
|
+
*
|
|
68
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
69
|
+
* defineModel (Two-Way Binding)
|
|
70
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
3
71
|
*
|
|
4
72
|
* The WCC component already emits `propNameChange` directly from _modelSet,
|
|
5
73
|
* so Angular's [(prop)] banana-box syntax works WITHOUT this adapter.
|
|
@@ -9,7 +77,7 @@
|
|
|
9
77
|
* 2. The ControlValueAccessor guide for ngModel support
|
|
10
78
|
*
|
|
11
79
|
* Setup (Angular):
|
|
12
|
-
* // No adapter import needed! Just use CUSTOM_ELEMENTS_SCHEMA:
|
|
80
|
+
* // No adapter import needed for [(prop)]! Just use CUSTOM_ELEMENTS_SCHEMA:
|
|
13
81
|
* import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
|
|
14
82
|
* @Component({ schemas: [CUSTOM_ELEMENTS_SCHEMA] })
|
|
15
83
|
*
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Angular adapter for WCC Scoped Slots.
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* - WccSlotDef: Auxiliary directive for ng-template[slot]
|
|
6
|
+
* - WccSlotsDirective: Main directive that auto-activates on custom elements
|
|
7
|
+
* - SlotContext: Interface for template context typing
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { WccSlotsDirective, WccSlotDef } from '@sprlab/wccompiler/adapters/angular';
|
|
11
|
+
*
|
|
12
|
+
* @Component({
|
|
13
|
+
* imports: [WccSlotsDirective, WccSlotDef],
|
|
14
|
+
* schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
15
|
+
* template: `
|
|
16
|
+
* <wcc-card>
|
|
17
|
+
* <ng-template slot="header"><strong>Header</strong></ng-template>
|
|
18
|
+
* <ng-template slot="stats" let-likes>{{ likes }} likes</ng-template>
|
|
19
|
+
* </wcc-card>
|
|
20
|
+
* `
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* @module @sprlab/wccompiler/adapters/angular
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
Directive,
|
|
28
|
+
TemplateRef,
|
|
29
|
+
ElementRef,
|
|
30
|
+
ViewContainerRef,
|
|
31
|
+
ChangeDetectorRef,
|
|
32
|
+
ContentChildren,
|
|
33
|
+
QueryList,
|
|
34
|
+
EmbeddedViewRef,
|
|
35
|
+
AfterContentInit,
|
|
36
|
+
OnDestroy,
|
|
37
|
+
inject,
|
|
38
|
+
Attribute,
|
|
39
|
+
} from '@angular/core';
|
|
40
|
+
|
|
41
|
+
// ─── Interfaces ─────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Context object passed to createEmbeddedView for scoped slots */
|
|
44
|
+
export interface SlotContext {
|
|
45
|
+
$implicit: any;
|
|
46
|
+
[key: string]: any;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type SlotType = 'named' | 'scoped';
|
|
50
|
+
|
|
51
|
+
interface SlotState {
|
|
52
|
+
type: SlotType;
|
|
53
|
+
slotDef: WccSlotDef;
|
|
54
|
+
viewRef: EmbeddedViewRef<SlotContext> | null;
|
|
55
|
+
cleanup: (() => void) | null;
|
|
56
|
+
wrapperEl: HTMLElement | null;
|
|
57
|
+
context: SlotContext | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── WccSlotDef — Auxiliary Directive ───────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Auxiliary directive that marks an ng-template as slot content.
|
|
64
|
+
* Captures the TemplateRef and the slot name from the HTML 'slot' attribute.
|
|
65
|
+
*
|
|
66
|
+
* Usage:
|
|
67
|
+
* <ng-template slot="header">...</ng-template>
|
|
68
|
+
* <ng-template slot="stats" let-likes>{{likes}}</ng-template>
|
|
69
|
+
*/
|
|
70
|
+
@Directive({
|
|
71
|
+
selector: 'ng-template[slot]',
|
|
72
|
+
standalone: true,
|
|
73
|
+
})
|
|
74
|
+
export class WccSlotDef {
|
|
75
|
+
public readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
|
|
76
|
+
public readonly slotName: string;
|
|
77
|
+
|
|
78
|
+
constructor(@Attribute('slot') name: string | null) {
|
|
79
|
+
this.slotName = name || '';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── WccSlotsDirective — Main Directive ─────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Exclusion selector: lists all standard HTML elements.
|
|
87
|
+
* Custom elements (which MUST contain a hyphen) are not excluded.
|
|
88
|
+
*/
|
|
89
|
+
const STANDARD_ELEMENTS = 'div,span,p,a,button,input,form,section,article,header,footer,nav,main,ul,ol,li,table,tr,td,th,thead,tbody,tfoot,img,h1,h2,h3,h4,h5,h6,label,select,textarea,option,fieldset,legend,details,summary,dialog,slot,template,canvas,video,audio,source,iframe,pre,code,blockquote,hr,br,strong,em,small,sub,sup,mark,del,ins,figure,figcaption,picture,svg,math,body,html,head,script,style,link,meta,title,base,col,colgroup,caption,abbr,address,area,aside,b,bdi,bdo,cite,data,dd,dfn,dl,dt,i,kbd,map,meter,noscript,output,progress,q,rp,rt,ruby,s,samp,time,u,var,wbr';
|
|
90
|
+
|
|
91
|
+
/** Build the exclusion selector string */
|
|
92
|
+
const EXCLUSION_SELECTOR = STANDARD_ELEMENTS.split(',').map(t => `:not(${t})`).join('');
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Main directive that auto-activates on custom elements (tags with hyphen).
|
|
96
|
+
* Classifies ng-template[slot] children as named or scoped slots and manages
|
|
97
|
+
* their lifecycle.
|
|
98
|
+
*/
|
|
99
|
+
@Directive({
|
|
100
|
+
selector: EXCLUSION_SELECTOR,
|
|
101
|
+
standalone: true,
|
|
102
|
+
})
|
|
103
|
+
export class WccSlotsDirective implements AfterContentInit, OnDestroy {
|
|
104
|
+
@ContentChildren(WccSlotDef) slotDefs!: QueryList<WccSlotDef>;
|
|
105
|
+
|
|
106
|
+
private el = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
107
|
+
private vcr = inject(ViewContainerRef);
|
|
108
|
+
private cdr = inject(ChangeDetectorRef);
|
|
109
|
+
|
|
110
|
+
private slots = new Map<string, SlotState>();
|
|
111
|
+
private eventCleanups: (() => void)[] = [];
|
|
112
|
+
private destroyed = false;
|
|
113
|
+
|
|
114
|
+
ngAfterContentInit(): void {
|
|
115
|
+
// Runtime guard: only proceed for custom elements (tag name contains hyphen)
|
|
116
|
+
if (!this.el.nativeElement.tagName.toLowerCase().includes('-')) return;
|
|
117
|
+
|
|
118
|
+
this.classifyAndInitSlots();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
ngOnDestroy(): void {
|
|
122
|
+
this.destroyed = true;
|
|
123
|
+
this.cleanup();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Classification ─────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/** Classifies slots using __scopedSlots from the host element and initializes them */
|
|
129
|
+
private classifyAndInitSlots(): void {
|
|
130
|
+
const element = this.el.nativeElement as any;
|
|
131
|
+
const scopedNames: string[] = element.__scopedSlots || [];
|
|
132
|
+
|
|
133
|
+
for (const slotDef of this.slotDefs) {
|
|
134
|
+
// Ignore templates with empty slot name
|
|
135
|
+
if (!slotDef.slotName) continue;
|
|
136
|
+
|
|
137
|
+
if (scopedNames.includes(slotDef.slotName)) {
|
|
138
|
+
this.initScopedSlot(slotDef);
|
|
139
|
+
} else {
|
|
140
|
+
this.initNamedSlot(slotDef);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Named Slot ─────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/** Named Slot: immediate static rendering */
|
|
148
|
+
private initNamedSlot(slotDef: WccSlotDef): void {
|
|
149
|
+
const hostEl = this.el.nativeElement;
|
|
150
|
+
const wrapper = document.createElement('div');
|
|
151
|
+
wrapper.setAttribute('slot', slotDef.slotName);
|
|
152
|
+
wrapper.style.display = 'contents';
|
|
153
|
+
|
|
154
|
+
const viewRef = this.vcr.createEmbeddedView(slotDef.templateRef);
|
|
155
|
+
for (const node of viewRef.rootNodes) {
|
|
156
|
+
wrapper.appendChild(node);
|
|
157
|
+
}
|
|
158
|
+
hostEl.appendChild(wrapper);
|
|
159
|
+
|
|
160
|
+
this.slots.set(slotDef.slotName, {
|
|
161
|
+
type: 'named',
|
|
162
|
+
slotDef,
|
|
163
|
+
viewRef,
|
|
164
|
+
cleanup: null,
|
|
165
|
+
wrapperEl: wrapper,
|
|
166
|
+
context: null,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Scoped Slot ────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/** Scoped Slot: async registration + reactive rendering */
|
|
173
|
+
private async initScopedSlot(slotDef: WccSlotDef): Promise<void> {
|
|
174
|
+
const hostEl = this.el.nativeElement;
|
|
175
|
+
const tagName = hostEl.tagName.toLowerCase();
|
|
176
|
+
|
|
177
|
+
// Wait for the custom element to be defined
|
|
178
|
+
await customElements.whenDefined(tagName);
|
|
179
|
+
if (this.destroyed) return;
|
|
180
|
+
|
|
181
|
+
const state: SlotState = {
|
|
182
|
+
type: 'scoped',
|
|
183
|
+
slotDef,
|
|
184
|
+
viewRef: null,
|
|
185
|
+
cleanup: null,
|
|
186
|
+
wrapperEl: null,
|
|
187
|
+
context: null,
|
|
188
|
+
};
|
|
189
|
+
this.slots.set(slotDef.slotName, state);
|
|
190
|
+
|
|
191
|
+
// Register renderer
|
|
192
|
+
const element = hostEl as any;
|
|
193
|
+
if (typeof element.registerSlotRenderer === 'function') {
|
|
194
|
+
state.cleanup = element.registerSlotRenderer(
|
|
195
|
+
slotDef.slotName,
|
|
196
|
+
(props: Record<string, any>) => this.renderSlot(slotDef.slotName, props)
|
|
197
|
+
);
|
|
198
|
+
} else {
|
|
199
|
+
// Fallback: listen for wcc:slot-update event
|
|
200
|
+
const handler = (e: CustomEvent) => {
|
|
201
|
+
if (e.detail?.slot === slotDef.slotName) {
|
|
202
|
+
this.renderSlot(slotDef.slotName, e.detail.props);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
hostEl.addEventListener('wcc:slot-update', handler as EventListener);
|
|
206
|
+
this.eventCleanups.push(() =>
|
|
207
|
+
hostEl.removeEventListener('wcc:slot-update', handler as EventListener)
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Context Construction ───────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Builds the Angular context for createEmbeddedView.
|
|
216
|
+
*
|
|
217
|
+
* Rules:
|
|
218
|
+
* - 0 props: $implicit = undefined
|
|
219
|
+
* - 1 prop: $implicit = that single value, plus the named prop key
|
|
220
|
+
* - N props (N > 1): $implicit = full props object, plus all named props
|
|
221
|
+
*/
|
|
222
|
+
buildContext(props: Record<string, any>): SlotContext {
|
|
223
|
+
const keys = Object.keys(props);
|
|
224
|
+
if (keys.length === 0) {
|
|
225
|
+
return { $implicit: undefined };
|
|
226
|
+
}
|
|
227
|
+
if (keys.length === 1) {
|
|
228
|
+
return { $implicit: props[keys[0]], ...props };
|
|
229
|
+
}
|
|
230
|
+
return { $implicit: props, ...props };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Render Slot ────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/** Creates or updates the EmbeddedView of a scoped slot */
|
|
236
|
+
private renderSlot(slotName: string, props: Record<string, any> | null): void {
|
|
237
|
+
const state = this.slots.get(slotName);
|
|
238
|
+
if (!state || this.destroyed) return;
|
|
239
|
+
|
|
240
|
+
// Props null/undefined: clear the view
|
|
241
|
+
if (props == null) {
|
|
242
|
+
if (state.viewRef) {
|
|
243
|
+
state.viewRef.destroy();
|
|
244
|
+
state.viewRef = null;
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const context = this.buildContext(props);
|
|
250
|
+
state.context = context;
|
|
251
|
+
|
|
252
|
+
if (state.viewRef) {
|
|
253
|
+
// Update existing context
|
|
254
|
+
Object.assign(state.viewRef.context, context);
|
|
255
|
+
state.viewRef.markForCheck();
|
|
256
|
+
} else {
|
|
257
|
+
// Create new view
|
|
258
|
+
state.viewRef = this.vcr.createEmbeddedView(state.slotDef.templateRef, context);
|
|
259
|
+
this.insertView(slotName, state);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.cdr.markForCheck();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── DOM Insertion ──────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/** Inserts view root nodes into the custom element's DOM via a wrapper div */
|
|
268
|
+
private insertView(slotName: string, state: SlotState): void {
|
|
269
|
+
if (!state.viewRef) return;
|
|
270
|
+
const hostEl = this.el.nativeElement;
|
|
271
|
+
|
|
272
|
+
if (!state.wrapperEl) {
|
|
273
|
+
state.wrapperEl = document.createElement('div');
|
|
274
|
+
state.wrapperEl.setAttribute('slot', slotName);
|
|
275
|
+
state.wrapperEl.style.display = 'contents';
|
|
276
|
+
hostEl.appendChild(state.wrapperEl);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Clear previous wrapper content and append new nodes
|
|
280
|
+
state.wrapperEl.innerHTML = '';
|
|
281
|
+
for (const node of state.viewRef.rootNodes) {
|
|
282
|
+
state.wrapperEl.appendChild(node);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Cleanup ────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/** Full cleanup on destroy */
|
|
289
|
+
private cleanup(): void {
|
|
290
|
+
// Destroy views, invoke cleanup functions, remove wrappers
|
|
291
|
+
for (const [, state] of this.slots) {
|
|
292
|
+
if (state.viewRef) {
|
|
293
|
+
state.viewRef.destroy();
|
|
294
|
+
}
|
|
295
|
+
if (state.cleanup) {
|
|
296
|
+
state.cleanup();
|
|
297
|
+
}
|
|
298
|
+
if (state.wrapperEl && state.wrapperEl.parentNode) {
|
|
299
|
+
state.wrapperEl.parentNode.removeChild(state.wrapperEl);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
this.slots.clear();
|
|
303
|
+
|
|
304
|
+
// Remove event listeners
|
|
305
|
+
for (const fn of this.eventCleanups) {
|
|
306
|
+
fn();
|
|
307
|
+
}
|
|
308
|
+
this.eventCleanups = [];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React browser-side hooks for WCC custom elements.
|
|
3
|
+
* Bridges CustomEvent to React's ref-based event system.
|
|
4
|
+
*
|
|
5
|
+
* @module @sprlab/wccompiler/adapters/react
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: Import hooks from THIS file (not from integrations/react).
|
|
8
|
+
* The integrations/react file is for vite.config.js only (contains Babel).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { useWccEvent, useWccModel } from '@sprlab/wccompiler/adapters/react'
|
|
12
|
+
*
|
|
13
|
+
* // Listen to custom events
|
|
14
|
+
* const ref = useWccEvent('change', (e) => console.log(e.detail))
|
|
15
|
+
* <wcc-counter ref={ref}></wcc-counter>
|
|
16
|
+
*
|
|
17
|
+
* // Two-way binding with defineModel
|
|
18
|
+
* const [text, setText] = useState('')
|
|
19
|
+
* const inputRef = useWccModel('value', text, setText)
|
|
20
|
+
* <wcc-input ref={inputRef}></wcc-input>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useRef, useEffect } from 'react'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Hook that attaches a CustomEvent listener to a DOM element via ref.
|
|
27
|
+
*
|
|
28
|
+
* Supports two calling conventions:
|
|
29
|
+
* - useWccEvent(ref, eventName, handler) — uses an existing ref
|
|
30
|
+
* - useWccEvent(eventName, handler) — creates and returns a new ref
|
|
31
|
+
*
|
|
32
|
+
* @param {import('react').RefObject<HTMLElement> | string} refOrEventName
|
|
33
|
+
* @param {string | ((event: CustomEvent) => void)} eventNameOrHandler
|
|
34
|
+
* @param {((event: CustomEvent) => void)} [handler]
|
|
35
|
+
* @returns {import('react').RefObject<HTMLElement> | void}
|
|
36
|
+
*/
|
|
37
|
+
export function useWccEvent(refOrEventName, eventNameOrHandler, handler) {
|
|
38
|
+
const isRefForm = typeof refOrEventName !== 'string'
|
|
39
|
+
const elementRef = isRefForm ? refOrEventName : useRef(null)
|
|
40
|
+
const eventName = isRefForm ? eventNameOrHandler : refOrEventName
|
|
41
|
+
const callback = isRefForm ? handler : eventNameOrHandler
|
|
42
|
+
|
|
43
|
+
const handlerRef = useRef(callback)
|
|
44
|
+
handlerRef.current = callback
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const el = elementRef.current
|
|
48
|
+
if (!el) return
|
|
49
|
+
const listener = (e) => handlerRef.current(e)
|
|
50
|
+
el.addEventListener(eventName, listener)
|
|
51
|
+
return () => el.removeEventListener(eventName, listener)
|
|
52
|
+
}, [eventName])
|
|
53
|
+
|
|
54
|
+
if (!isRefForm) return elementRef
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hook for two-way binding with WCC defineModel props.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} propName - The model prop name (e.g., 'value', 'count')
|
|
61
|
+
* @param {*} value - Current React state value
|
|
62
|
+
* @param {(newValue: *) => void} setValue - React state setter
|
|
63
|
+
* @param {import('react').RefObject<HTMLElement>} [existingRef] - Optional existing ref
|
|
64
|
+
* @returns {import('react').RefObject<HTMLElement>} Ref to attach to the WCC element
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* const [text, setText] = useState('')
|
|
68
|
+
* const inputRef = useWccModel('value', text, setText)
|
|
69
|
+
* <wcc-input ref={inputRef}></wcc-input>
|
|
70
|
+
*/
|
|
71
|
+
export function useWccModel(propName, value, setValue, existingRef) {
|
|
72
|
+
const internalRef = useRef(null)
|
|
73
|
+
const elementRef = existingRef || internalRef
|
|
74
|
+
|
|
75
|
+
const setValueRef = useRef(setValue)
|
|
76
|
+
setValueRef.current = setValue
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const el = elementRef.current
|
|
80
|
+
if (!el) return
|
|
81
|
+
|
|
82
|
+
const listener = (e) => {
|
|
83
|
+
if (e.detail && e.detail.prop === propName) {
|
|
84
|
+
setValueRef.current(e.detail.value)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
el.addEventListener('wcc:model', listener)
|
|
89
|
+
return () => el.removeEventListener('wcc:model', listener)
|
|
90
|
+
}, [propName])
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const el = elementRef.current
|
|
94
|
+
if (!el) return
|
|
95
|
+
if (value != null) {
|
|
96
|
+
el.setAttribute(propName, String(value))
|
|
97
|
+
} else {
|
|
98
|
+
el.removeAttribute(propName)
|
|
99
|
+
}
|
|
100
|
+
}, [propName, value])
|
|
101
|
+
|
|
102
|
+
return elementRef
|
|
103
|
+
}
|
package/integrations/react.js
CHANGED
|
@@ -1,126 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* React
|
|
3
|
-
*
|
|
2
|
+
* React Vite plugin for WCC custom elements.
|
|
3
|
+
* Transforms idiomatic React JSX slot patterns into WCC-compatible slot markup.
|
|
4
4
|
*
|
|
5
5
|
* @module @sprlab/wccompiler/integrations/react
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* const ref = useRef(null)
|
|
10
|
-
* useWccEvent(ref, 'change', (e) => console.log(e.detail))
|
|
11
|
-
* <wcc-counter ref={ref}></wcc-counter>
|
|
7
|
+
* IMPORTANT: This file is for vite.config.js (Node.js context) ONLY.
|
|
8
|
+
* For browser-side hooks, import from '@sprlab/wccompiler/adapters/react'.
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* @example vite.config.js
|
|
11
|
+
* ```js
|
|
12
|
+
* import { wccReactPlugin } from '@sprlab/wccompiler/integrations/react'
|
|
13
|
+
* export default { plugins: [wccReactPlugin()] }
|
|
14
|
+
* ```
|
|
16
15
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* @example Component (browser — import hooks from adapters)
|
|
17
|
+
* ```jsx
|
|
18
|
+
* import { useWccEvent, useWccModel } from '@sprlab/wccompiler/adapters/react'
|
|
19
|
+
* ```
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
|
-
import { useRef, useEffect, useCallback } from 'react'
|
|
24
22
|
import { parse } from '@babel/parser'
|
|
25
23
|
import _traverse from '@babel/traverse'
|
|
26
24
|
import _generate from '@babel/generator'
|
|
27
25
|
const traverse = _traverse.default || _traverse
|
|
28
26
|
const generate = _generate.default || _generate
|
|
29
27
|
|
|
30
|
-
/**
|
|
31
|
-
* Hook that attaches a CustomEvent listener to a DOM element via ref.
|
|
32
|
-
*
|
|
33
|
-
* Supports two calling conventions:
|
|
34
|
-
* - useWccEvent(ref, eventName, handler) — uses an existing ref
|
|
35
|
-
* - useWccEvent(eventName, handler) — creates and returns a new ref
|
|
36
|
-
*
|
|
37
|
-
* @param {import('react').RefObject<HTMLElement> | string} refOrEventName
|
|
38
|
-
* @param {string | ((event: CustomEvent) => void)} eventNameOrHandler
|
|
39
|
-
* @param {((event: CustomEvent) => void)} [handler]
|
|
40
|
-
* @returns {import('react').RefObject<HTMLElement> | void}
|
|
41
|
-
*/
|
|
42
|
-
export function useWccEvent(refOrEventName, eventNameOrHandler, handler) {
|
|
43
|
-
// Detect calling convention
|
|
44
|
-
const isRefForm = typeof refOrEventName !== 'string'
|
|
45
|
-
const elementRef = isRefForm ? refOrEventName : useRef(null)
|
|
46
|
-
const eventName = isRefForm ? eventNameOrHandler : refOrEventName
|
|
47
|
-
const callback = isRefForm ? handler : eventNameOrHandler
|
|
48
|
-
|
|
49
|
-
const handlerRef = useRef(callback)
|
|
50
|
-
handlerRef.current = callback
|
|
51
|
-
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
const el = elementRef.current
|
|
54
|
-
if (!el) return
|
|
55
|
-
const listener = (e) => handlerRef.current(e)
|
|
56
|
-
el.addEventListener(eventName, listener)
|
|
57
|
-
return () => el.removeEventListener(eventName, listener)
|
|
58
|
-
}, [eventName])
|
|
59
|
-
|
|
60
|
-
// Only return ref if we created it (Form 2)
|
|
61
|
-
if (!isRefForm) return elementRef
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Hook for two-way binding with WCC defineModel props.
|
|
67
|
-
*
|
|
68
|
-
* Listens for `wcc:model` events on the element and calls the setter
|
|
69
|
-
* when the matching prop changes internally. Also syncs the React state
|
|
70
|
-
* to the element's attribute when the value changes externally.
|
|
71
|
-
*
|
|
72
|
-
* @param {string} propName - The model prop name (e.g., 'value', 'count')
|
|
73
|
-
* @param {*} value - Current React state value
|
|
74
|
-
* @param {(newValue: *) => void} setValue - React state setter
|
|
75
|
-
* @param {import('react').RefObject<HTMLElement>} [existingRef] - Optional existing ref
|
|
76
|
-
* @returns {import('react').RefObject<HTMLElement>} Ref to attach to the WCC element
|
|
77
|
-
*
|
|
78
|
-
* @example
|
|
79
|
-
* ```jsx
|
|
80
|
-
* function App() {
|
|
81
|
-
* const [text, setText] = useState('')
|
|
82
|
-
* const inputRef = useWccModel('value', text, setText)
|
|
83
|
-
* return <wcc-input ref={inputRef}></wcc-input>
|
|
84
|
-
* }
|
|
85
|
-
* ```
|
|
86
|
-
*/
|
|
87
|
-
export function useWccModel(propName, value, setValue, existingRef) {
|
|
88
|
-
const internalRef = useRef(null)
|
|
89
|
-
const elementRef = existingRef || internalRef
|
|
90
|
-
|
|
91
|
-
const setValueRef = useRef(setValue)
|
|
92
|
-
setValueRef.current = setValue
|
|
93
|
-
|
|
94
|
-
// Listen for wcc:model events from the component (child → parent)
|
|
95
|
-
useEffect(() => {
|
|
96
|
-
const el = elementRef.current
|
|
97
|
-
if (!el) return
|
|
98
|
-
|
|
99
|
-
const listener = (e) => {
|
|
100
|
-
if (e.detail && e.detail.prop === propName) {
|
|
101
|
-
setValueRef.current(e.detail.value)
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
el.addEventListener('wcc:model', listener)
|
|
106
|
-
return () => el.removeEventListener('wcc:model', listener)
|
|
107
|
-
}, [propName])
|
|
108
|
-
|
|
109
|
-
// Sync React state to the element's attribute (parent → child)
|
|
110
|
-
useEffect(() => {
|
|
111
|
-
const el = elementRef.current
|
|
112
|
-
if (!el) return
|
|
113
|
-
if (value != null) {
|
|
114
|
-
el.setAttribute(propName, String(value))
|
|
115
|
-
} else {
|
|
116
|
-
el.removeAttribute(propName)
|
|
117
|
-
}
|
|
118
|
-
}, [propName, value])
|
|
119
|
-
|
|
120
|
-
return elementRef
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
28
|
/**
|
|
125
29
|
* JSX attribute name to HTML attribute name mapping.
|
|
126
30
|
* React uses camelCase for some attributes that HTML uses lowercase.
|
package/lib/codegen.js
CHANGED
|
@@ -962,10 +962,24 @@ export function generateComponent(parseResult, options = {}) {
|
|
|
962
962
|
lines.push('');
|
|
963
963
|
}
|
|
964
964
|
|
|
965
|
+
// Static __scopedSlots array (lists slot names with reactive props)
|
|
966
|
+
const scopedSlotNames = slots.filter(s => s.name && s.slotProps.length > 0).map(s => s.name);
|
|
967
|
+
if (scopedSlotNames.length > 0) {
|
|
968
|
+
const scopedArr = scopedSlotNames.map(n => `'${n}'`).join(', ');
|
|
969
|
+
lines.push(` static __scopedSlots = [${scopedArr}];`);
|
|
970
|
+
lines.push('');
|
|
971
|
+
}
|
|
972
|
+
|
|
965
973
|
// Constructor — reactive state only (no DOM manipulation per Custom Elements spec)
|
|
966
974
|
lines.push(' constructor() {');
|
|
967
975
|
lines.push(' super();');
|
|
968
976
|
|
|
977
|
+
// Scoped slot storage initialization
|
|
978
|
+
if (scopedSlotNames.length > 0) {
|
|
979
|
+
lines.push(' this.__slotRenderers = {};');
|
|
980
|
+
lines.push(' this.__slotProps = {};');
|
|
981
|
+
}
|
|
982
|
+
|
|
969
983
|
// Prop signal initialization (BEFORE user signals)
|
|
970
984
|
for (const p of propDefs) {
|
|
971
985
|
lines.push(` this._s_${p.name} = __signal(${p.default});`);
|
|
@@ -1277,11 +1291,20 @@ export function generateComponent(parseResult, options = {}) {
|
|
|
1277
1291
|
lines.push(` if (this.__slotTpl_${s.name}) {`);
|
|
1278
1292
|
lines.push(' __effect(() => {');
|
|
1279
1293
|
lines.push(` const __props = { ${propsObj} };`);
|
|
1280
|
-
|
|
1281
|
-
lines.push(
|
|
1282
|
-
|
|
1294
|
+
// Task 3.1: Store current props in __slotProps
|
|
1295
|
+
lines.push(` this.__slotProps['${s.name}'] = __props;`);
|
|
1296
|
+
// Task 3.2: Emit wcc:slot-update event
|
|
1297
|
+
lines.push(` this.dispatchEvent(new CustomEvent('wcc:slot-update', { detail: { slot: '${s.name}', props: __props }, bubbles: false }));`);
|
|
1298
|
+
// Task 3.3: Check for registered renderer, skip token replacement if present
|
|
1299
|
+
lines.push(` if (this.__slotRenderers && this.__slotRenderers['${s.name}']) {`);
|
|
1300
|
+
lines.push(` this.__slotRenderers['${s.name}'](__props);`);
|
|
1301
|
+
lines.push(' } else {');
|
|
1302
|
+
lines.push(` let __html = this.__slotTpl_${s.name};`);
|
|
1303
|
+
lines.push(" for (const [k, v] of Object.entries(__props)) {");
|
|
1304
|
+
lines.push(` __html = __html.replace(new RegExp('(?:\\\\{\\\\{|\\\\{%)\\\\s*' + k + '(\\\\(\\\\))?\\\\s*(?:\\\\}\\\\}|%\\\\})', 'g'), v ?? '');`);
|
|
1305
|
+
lines.push(' }');
|
|
1306
|
+
lines.push(` this.${s.varName}.innerHTML = __html;`);
|
|
1283
1307
|
lines.push(' }');
|
|
1284
|
-
lines.push(` this.${s.varName}.innerHTML = __html;`);
|
|
1285
1308
|
lines.push(' });');
|
|
1286
1309
|
lines.push(' }');
|
|
1287
1310
|
}
|
|
@@ -1769,6 +1792,25 @@ export function generateComponent(parseResult, options = {}) {
|
|
|
1769
1792
|
lines.push('');
|
|
1770
1793
|
}
|
|
1771
1794
|
|
|
1795
|
+
// __scopedSlots instance getter and registerSlotRenderer (if scoped slots exist)
|
|
1796
|
+
if (scopedSlotNames.length > 0) {
|
|
1797
|
+
lines.push(' get __scopedSlots() { return this.constructor.__scopedSlots || []; }');
|
|
1798
|
+
lines.push('');
|
|
1799
|
+
lines.push(' registerSlotRenderer(slotName, callback) {');
|
|
1800
|
+
lines.push(' if (!this.__slotRenderers) this.__slotRenderers = {};');
|
|
1801
|
+
lines.push(' this.__slotRenderers[slotName] = callback;');
|
|
1802
|
+
lines.push(' if (this.__slotProps && this.__slotProps[slotName]) {');
|
|
1803
|
+
lines.push(' callback(this.__slotProps[slotName]);');
|
|
1804
|
+
lines.push(' }');
|
|
1805
|
+
lines.push(' return () => {');
|
|
1806
|
+
lines.push(' if (this.__slotRenderers) {');
|
|
1807
|
+
lines.push(' delete this.__slotRenderers[slotName];');
|
|
1808
|
+
lines.push(' }');
|
|
1809
|
+
lines.push(' };');
|
|
1810
|
+
lines.push(' }');
|
|
1811
|
+
lines.push('');
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1772
1814
|
// User methods (prefixed with _)
|
|
1773
1815
|
for (const m of methods) {
|
|
1774
1816
|
const body = transformMethodBody(m.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames, modelVarMap);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sprlab/wccompiler",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
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": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"./integrations/react": "./integrations/react.js",
|
|
10
10
|
"./integrations/angular": "./integrations/angular.js",
|
|
11
11
|
"./adapters/vue": "./adapters/vue.js",
|
|
12
|
-
"./adapters/angular": "./adapters/angular.js"
|
|
12
|
+
"./adapters/angular": "./adapters/angular.js",
|
|
13
|
+
"./adapters/react": "./adapters/react.js"
|
|
13
14
|
},
|
|
14
15
|
"bin": {
|
|
15
16
|
"wcc": "./bin/wcc.js"
|