@sprlab/wccompiler 0.13.0 → 0.15.0
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/README.md +998 -998
- package/adapters/angular-compiled/angular.d.ts +197 -197
- package/adapters/angular-compiled/angular.mjs +488 -488
- package/adapters/angular.js +54 -54
- package/adapters/angular.ts +630 -630
- package/adapters/react.js +114 -114
- package/adapters/vue.js +103 -103
- package/bin/wcc.js +412 -412
- package/bin/wcc.test.js +126 -126
- package/integrations/angular.js +73 -73
- package/integrations/react.js +859 -859
- package/integrations/vue.js +253 -253
- package/lib/codegen.js +2078 -2074
- package/lib/compiler-browser.js +545 -545
- package/lib/compiler.js +483 -479
- package/lib/config.js +71 -71
- package/lib/css-scoper.js +180 -180
- package/lib/dev-server.js +193 -193
- package/lib/import-resolver.js +160 -160
- package/lib/parser-extractors.js +1240 -1169
- package/lib/parser.js +273 -269
- package/lib/reactive-runtime.js +143 -143
- package/lib/sfc-parser.js +333 -333
- package/lib/template-normalizer.js +114 -114
- package/lib/tree-walker.js +1013 -1013
- package/lib/types.js +262 -262
- package/lib/wcc-runtime.js +68 -68
- package/package.json +85 -85
- package/types/wcc.d.ts +28 -28
- package/types/wcc.test.js +46 -46
package/adapters/angular.ts
CHANGED
|
@@ -1,630 +1,630 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Angular adapter for WCC Scoped Slots and Event Binding.
|
|
3
|
-
*
|
|
4
|
-
* Exports:
|
|
5
|
-
* - WccSlotDef: Auxiliary directive for ng-template[slot]
|
|
6
|
-
* - WccSlotsDirective: Main directive activated via [wccSlots] attribute
|
|
7
|
-
* - WccEvent: Single-event directive (wccEvent="name" + wccEmit output)
|
|
8
|
-
* - WccEvents: Multi-event bridging directive (kebab-case → camelCase)
|
|
9
|
-
* - WccModel: Two-way binding bridge for [(prop)] banana-box syntax
|
|
10
|
-
* - SlotContext: Interface for template context typing
|
|
11
|
-
*
|
|
12
|
-
* Usage:
|
|
13
|
-
* import { WccSlotsDirective, WccSlotDef, WccEvent, WccEvents } from '@sprlab/wccompiler/adapters/angular';
|
|
14
|
-
*
|
|
15
|
-
* @Component({
|
|
16
|
-
* imports: [WccSlotsDirective, WccSlotDef, WccEvent, WccEvents],
|
|
17
|
-
* schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
18
|
-
* template: `
|
|
19
|
-
* <wcc-card wccSlots>
|
|
20
|
-
* <ng-template slot="header"><strong>Header</strong></ng-template>
|
|
21
|
-
* <ng-template slot="stats" let-likes>{{ likes }} likes</ng-template>
|
|
22
|
-
* </wcc-card>
|
|
23
|
-
*
|
|
24
|
-
* <!-- Event binding option 1: single event with unwrapped detail -->
|
|
25
|
-
* <wcc-counter wccEvent="count-changed" (wccEmit)="onCount($event)"></wcc-counter>
|
|
26
|
-
*
|
|
27
|
-
* <!-- Event binding option 2: camelCase event names -->
|
|
28
|
-
* <wcc-counter wccEvents (countChanged)="onCount($event.detail)"></wcc-counter>
|
|
29
|
-
*
|
|
30
|
-
* <!-- Event binding option 3: standard Angular (always works) -->
|
|
31
|
-
* <wcc-counter (count-changed)="onCount($event.detail)"></wcc-counter>
|
|
32
|
-
* `
|
|
33
|
-
* })
|
|
34
|
-
*
|
|
35
|
-
* Note: Add the `wccSlots` attribute to any WCC element that uses slots.
|
|
36
|
-
* This is required because Angular AOT cannot evaluate dynamic selectors.
|
|
37
|
-
*
|
|
38
|
-
* @module @sprlab/wccompiler/adapters/angular
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
import {
|
|
42
|
-
Directive,
|
|
43
|
-
TemplateRef,
|
|
44
|
-
ElementRef,
|
|
45
|
-
ViewContainerRef,
|
|
46
|
-
ChangeDetectorRef,
|
|
47
|
-
ContentChildren,
|
|
48
|
-
QueryList,
|
|
49
|
-
EmbeddedViewRef,
|
|
50
|
-
AfterContentInit,
|
|
51
|
-
OnDestroy,
|
|
52
|
-
OnInit,
|
|
53
|
-
Output,
|
|
54
|
-
EventEmitter,
|
|
55
|
-
inject,
|
|
56
|
-
Attribute,
|
|
57
|
-
Input,
|
|
58
|
-
} from '@angular/core';
|
|
59
|
-
|
|
60
|
-
// ─── Interfaces ─────────────────────────────────────────────────────────────
|
|
61
|
-
|
|
62
|
-
/** Context object passed to createEmbeddedView for scoped slots */
|
|
63
|
-
export interface SlotContext {
|
|
64
|
-
$implicit: any;
|
|
65
|
-
[key: string]: any;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
type SlotType = 'named' | 'scoped';
|
|
69
|
-
|
|
70
|
-
interface SlotState {
|
|
71
|
-
type: SlotType;
|
|
72
|
-
slotDef: WccSlotDef;
|
|
73
|
-
viewRef: EmbeddedViewRef<SlotContext> | null;
|
|
74
|
-
cleanup: (() => void) | null;
|
|
75
|
-
wrapperEl: HTMLElement | null;
|
|
76
|
-
context: SlotContext | null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ─── WccSlotDef — Auxiliary Directive ───────────────────────────────────────
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Auxiliary directive that marks an ng-template as slot content.
|
|
83
|
-
* Captures the TemplateRef and the slot name from the HTML 'slot' attribute.
|
|
84
|
-
*
|
|
85
|
-
* Usage:
|
|
86
|
-
* <ng-template slot="header">...</ng-template>
|
|
87
|
-
* <ng-template slot="stats" let-likes>{{likes}}</ng-template>
|
|
88
|
-
*/
|
|
89
|
-
@Directive({
|
|
90
|
-
selector: 'ng-template[slot]',
|
|
91
|
-
standalone: true,
|
|
92
|
-
})
|
|
93
|
-
export class WccSlotDef {
|
|
94
|
-
public readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
|
|
95
|
-
public readonly slotName: string;
|
|
96
|
-
|
|
97
|
-
constructor(@Attribute('slot') name: string | null) {
|
|
98
|
-
this.slotName = name || '';
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ─── WccSlotsDirective — Main Directive ─────────────────────────────────────
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Main directive that activates on elements with the [wccSlots] attribute.
|
|
106
|
-
* Classifies ng-template[slot] children as named or scoped slots and manages
|
|
107
|
-
* their lifecycle.
|
|
108
|
-
*
|
|
109
|
-
* Uses a simple attribute selector `[wccSlots]` instead of a dynamic exclusion
|
|
110
|
-
* selector, because Angular AOT cannot evaluate computed selector expressions.
|
|
111
|
-
*/
|
|
112
|
-
@Directive({
|
|
113
|
-
selector: '[wccSlots]',
|
|
114
|
-
standalone: true,
|
|
115
|
-
})
|
|
116
|
-
export class WccSlotsDirective implements AfterContentInit, OnDestroy {
|
|
117
|
-
@ContentChildren(WccSlotDef) slotDefs!: QueryList<WccSlotDef>;
|
|
118
|
-
|
|
119
|
-
private el = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
120
|
-
private vcr = inject(ViewContainerRef);
|
|
121
|
-
private cdr = inject(ChangeDetectorRef);
|
|
122
|
-
|
|
123
|
-
private slots = new Map<string, SlotState>();
|
|
124
|
-
private eventCleanups: (() => void)[] = [];
|
|
125
|
-
private destroyed = false;
|
|
126
|
-
|
|
127
|
-
ngAfterContentInit(): void {
|
|
128
|
-
// Runtime guard: only proceed for custom elements (tag name contains hyphen)
|
|
129
|
-
if (!this.el.nativeElement.tagName.toLowerCase().includes('-')) return;
|
|
130
|
-
|
|
131
|
-
// Normalize Angular-style slot attributes: slot-header → slot="header"
|
|
132
|
-
this.normalizeSlotAttributes();
|
|
133
|
-
|
|
134
|
-
this.classifyAndInitSlots();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
ngOnDestroy(): void {
|
|
138
|
-
this.destroyed = true;
|
|
139
|
-
this.cleanup();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// ─── Slot Attribute Normalization ───────────────────────────────────────
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Normalizes Angular-style slot attributes to standard HTML slot attributes.
|
|
146
|
-
* Converts: <div slot-header> → <div slot="header">
|
|
147
|
-
*
|
|
148
|
-
* This enables the Angular ng-content select pattern:
|
|
149
|
-
* <wcc-card wccSlots>
|
|
150
|
-
* <nav slot-header>Title</nav>
|
|
151
|
-
* <span slot-footer>Footer</span>
|
|
152
|
-
* </wcc-card>
|
|
153
|
-
*
|
|
154
|
-
* Skips reserved prefixes: slot-props, slot-template-*
|
|
155
|
-
*/
|
|
156
|
-
private normalizeSlotAttributes(): void {
|
|
157
|
-
const hostEl = this.el.nativeElement;
|
|
158
|
-
for (const child of Array.from(hostEl.children)) {
|
|
159
|
-
for (const attr of Array.from(child.attributes)) {
|
|
160
|
-
if (
|
|
161
|
-
attr.name.startsWith('slot-') &&
|
|
162
|
-
!attr.value &&
|
|
163
|
-
attr.name !== 'slot-props' &&
|
|
164
|
-
!attr.name.startsWith('slot-template-')
|
|
165
|
-
) {
|
|
166
|
-
const slotName = attr.name.slice(5); // "slot-header" → "header"
|
|
167
|
-
child.removeAttribute(attr.name);
|
|
168
|
-
child.setAttribute('slot', slotName);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ─── Classification ─────────────────────────────────────────────────────
|
|
175
|
-
|
|
176
|
-
/** Classifies slots using __scopedSlots from the host element and initializes them */
|
|
177
|
-
private async classifyAndInitSlots(): Promise<void> {
|
|
178
|
-
const hostEl = this.el.nativeElement;
|
|
179
|
-
const tagName = hostEl.tagName.toLowerCase();
|
|
180
|
-
|
|
181
|
-
// Wait for the custom element to be defined (ensures the class is upgraded)
|
|
182
|
-
await customElements.whenDefined(tagName);
|
|
183
|
-
if (this.destroyed) return;
|
|
184
|
-
|
|
185
|
-
const element = hostEl as any;
|
|
186
|
-
// Read from instance getter or static property
|
|
187
|
-
const scopedNames: string[] = element.__scopedSlots
|
|
188
|
-
|| (element.constructor && element.constructor.__scopedSlots)
|
|
189
|
-
|| [];
|
|
190
|
-
|
|
191
|
-
for (const slotDef of this.slotDefs) {
|
|
192
|
-
if (!slotDef.slotName) continue;
|
|
193
|
-
|
|
194
|
-
if (scopedNames.includes(slotDef.slotName)) {
|
|
195
|
-
this.initScopedSlot(slotDef);
|
|
196
|
-
} else {
|
|
197
|
-
this.initNamedSlot(slotDef);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ─── Named Slot ─────────────────────────────────────────────────────────
|
|
203
|
-
|
|
204
|
-
/** Named Slot: immediate static rendering */
|
|
205
|
-
private initNamedSlot(slotDef: WccSlotDef): void {
|
|
206
|
-
const hostEl = this.el.nativeElement;
|
|
207
|
-
|
|
208
|
-
// Strategy 1: Find [data-slot] container inside the component's internal DOM
|
|
209
|
-
const dataSlotEl = hostEl.querySelector(`[data-slot="${slotDef.slotName}"]`);
|
|
210
|
-
let wrapper: HTMLElement;
|
|
211
|
-
|
|
212
|
-
if (dataSlotEl) {
|
|
213
|
-
// Use the data-slot element directly — clear fallback content and insert rendered nodes
|
|
214
|
-
wrapper = dataSlotEl as HTMLElement;
|
|
215
|
-
wrapper.innerHTML = '';
|
|
216
|
-
} else {
|
|
217
|
-
// Strategy 2: Fallback for Shadow DOM / native <slot> elements
|
|
218
|
-
wrapper = document.createElement('div');
|
|
219
|
-
wrapper.setAttribute('slot', slotDef.slotName);
|
|
220
|
-
wrapper.style.display = 'contents';
|
|
221
|
-
hostEl.appendChild(wrapper);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const viewRef = this.vcr.createEmbeddedView(slotDef.templateRef);
|
|
225
|
-
for (const node of viewRef.rootNodes) {
|
|
226
|
-
wrapper.appendChild(node);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
this.slots.set(slotDef.slotName, {
|
|
230
|
-
type: 'named',
|
|
231
|
-
slotDef,
|
|
232
|
-
viewRef,
|
|
233
|
-
cleanup: null,
|
|
234
|
-
wrapperEl: wrapper,
|
|
235
|
-
context: null,
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
this.cdr.detectChanges();
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// ─── Scoped Slot ────────────────────────────────────────────────────────
|
|
242
|
-
|
|
243
|
-
/** Scoped Slot: registration + reactive rendering */
|
|
244
|
-
private initScopedSlot(slotDef: WccSlotDef): void {
|
|
245
|
-
const hostEl = this.el.nativeElement;
|
|
246
|
-
|
|
247
|
-
const state: SlotState = {
|
|
248
|
-
type: 'scoped',
|
|
249
|
-
slotDef,
|
|
250
|
-
viewRef: null,
|
|
251
|
-
cleanup: null,
|
|
252
|
-
wrapperEl: null,
|
|
253
|
-
context: null,
|
|
254
|
-
};
|
|
255
|
-
this.slots.set(slotDef.slotName, state);
|
|
256
|
-
|
|
257
|
-
// Register renderer
|
|
258
|
-
const element = hostEl as any;
|
|
259
|
-
if (typeof element.registerSlotRenderer === 'function') {
|
|
260
|
-
state.cleanup = element.registerSlotRenderer(
|
|
261
|
-
slotDef.slotName,
|
|
262
|
-
(props: Record<string, any>) => this.renderSlot(slotDef.slotName, props)
|
|
263
|
-
);
|
|
264
|
-
} else {
|
|
265
|
-
// Fallback: listen for wcc:slot-update event
|
|
266
|
-
const handler = (e: CustomEvent) => {
|
|
267
|
-
if (e.detail?.slot === slotDef.slotName) {
|
|
268
|
-
this.renderSlot(slotDef.slotName, e.detail.props);
|
|
269
|
-
}
|
|
270
|
-
};
|
|
271
|
-
hostEl.addEventListener('wcc:slot-update', handler as EventListener);
|
|
272
|
-
this.eventCleanups.push(() =>
|
|
273
|
-
hostEl.removeEventListener('wcc:slot-update', handler as EventListener)
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ─── Context Construction ───────────────────────────────────────────────
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Builds the Angular context for createEmbeddedView.
|
|
282
|
-
*
|
|
283
|
-
* Rules:
|
|
284
|
-
* - 0 props: $implicit = undefined
|
|
285
|
-
* - 1 prop: $implicit = that single value, plus the named prop key
|
|
286
|
-
* - N props (N > 1): $implicit = full props object, plus all named props
|
|
287
|
-
*/
|
|
288
|
-
buildContext(props: Record<string, any>): SlotContext {
|
|
289
|
-
const keys = Object.keys(props);
|
|
290
|
-
if (keys.length === 0) {
|
|
291
|
-
return { $implicit: undefined };
|
|
292
|
-
}
|
|
293
|
-
if (keys.length === 1) {
|
|
294
|
-
return { $implicit: props[keys[0]], ...props };
|
|
295
|
-
}
|
|
296
|
-
return { $implicit: props, ...props };
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// ─── Render Slot ────────────────────────────────────────────────────────
|
|
300
|
-
|
|
301
|
-
/** Creates or updates the EmbeddedView of a scoped slot */
|
|
302
|
-
private renderSlot(slotName: string, props: Record<string, any> | null): void {
|
|
303
|
-
const state = this.slots.get(slotName);
|
|
304
|
-
if (!state || this.destroyed) return;
|
|
305
|
-
|
|
306
|
-
if (props == null) {
|
|
307
|
-
if (state.viewRef) {
|
|
308
|
-
state.viewRef.destroy();
|
|
309
|
-
state.viewRef = null;
|
|
310
|
-
}
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const context = this.buildContext(props);
|
|
315
|
-
state.context = context;
|
|
316
|
-
|
|
317
|
-
if (state.viewRef) {
|
|
318
|
-
// Update existing view context
|
|
319
|
-
Object.assign(state.viewRef.context, context);
|
|
320
|
-
state.viewRef.markForCheck();
|
|
321
|
-
// Re-insert nodes to reflect updated content (Angular doesn't auto-update DOM for detached views)
|
|
322
|
-
if (state.wrapperEl) {
|
|
323
|
-
state.wrapperEl.innerHTML = '';
|
|
324
|
-
for (const node of state.viewRef.rootNodes) {
|
|
325
|
-
state.wrapperEl.appendChild(node);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
} else {
|
|
329
|
-
state.viewRef = this.vcr.createEmbeddedView(state.slotDef.templateRef, context);
|
|
330
|
-
this.insertView(slotName, state);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
this.cdr.detectChanges();
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ─── DOM Insertion ──────────────────────────────────────────────────────
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Inserts view root nodes into the custom element's DOM.
|
|
340
|
-
*
|
|
341
|
-
* Strategy:
|
|
342
|
-
* 1. Look for a [data-slot="slotName"] element inside the component (non-Shadow DOM)
|
|
343
|
-
* → clear its content and insert the rendered nodes there
|
|
344
|
-
* 2. Fallback: append a wrapper <div slot="slotName"> to the host (Shadow DOM / native slots)
|
|
345
|
-
*/
|
|
346
|
-
private insertView(slotName: string, state: SlotState): void {
|
|
347
|
-
if (!state.viewRef) return;
|
|
348
|
-
const hostEl = this.el.nativeElement;
|
|
349
|
-
|
|
350
|
-
// Strategy 1: Find [data-slot] container inside the component's internal DOM
|
|
351
|
-
const dataSlotEl = hostEl.querySelector(`[data-slot="${slotName}"]`);
|
|
352
|
-
if (dataSlotEl) {
|
|
353
|
-
// Use the data-slot element as the wrapper (no extra div needed)
|
|
354
|
-
state.wrapperEl = dataSlotEl as HTMLElement;
|
|
355
|
-
state.wrapperEl.innerHTML = '';
|
|
356
|
-
for (const node of state.viewRef.rootNodes) {
|
|
357
|
-
state.wrapperEl.appendChild(node);
|
|
358
|
-
}
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Strategy 2: Fallback for Shadow DOM / native <slot> elements
|
|
363
|
-
if (!state.wrapperEl) {
|
|
364
|
-
state.wrapperEl = document.createElement('div');
|
|
365
|
-
state.wrapperEl.setAttribute('slot', slotName);
|
|
366
|
-
state.wrapperEl.style.display = 'contents';
|
|
367
|
-
hostEl.appendChild(state.wrapperEl);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
state.wrapperEl.innerHTML = '';
|
|
371
|
-
for (const node of state.viewRef.rootNodes) {
|
|
372
|
-
state.wrapperEl.appendChild(node);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// ─── Cleanup ────────────────────────────────────────────────────────────
|
|
377
|
-
|
|
378
|
-
/** Full cleanup on destroy */
|
|
379
|
-
private cleanup(): void {
|
|
380
|
-
for (const [, state] of this.slots) {
|
|
381
|
-
if (state.viewRef) {
|
|
382
|
-
state.viewRef.destroy();
|
|
383
|
-
}
|
|
384
|
-
if (state.cleanup) {
|
|
385
|
-
state.cleanup();
|
|
386
|
-
}
|
|
387
|
-
if (state.wrapperEl) {
|
|
388
|
-
// If the wrapper is a [data-slot] element (part of the component's internal DOM),
|
|
389
|
-
// just clear its content rather than removing it from the DOM
|
|
390
|
-
if (state.wrapperEl.hasAttribute('data-slot')) {
|
|
391
|
-
state.wrapperEl.innerHTML = '';
|
|
392
|
-
} else if (state.wrapperEl.parentNode) {
|
|
393
|
-
state.wrapperEl.parentNode.removeChild(state.wrapperEl);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
this.slots.clear();
|
|
398
|
-
|
|
399
|
-
for (const fn of this.eventCleanups) {
|
|
400
|
-
fn();
|
|
401
|
-
}
|
|
402
|
-
this.eventCleanups = [];
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
// ─── WccEvent — Event Binding Directive ─────────────────────────────────────
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Directive that bridges WCC custom element events to Angular output bindings.
|
|
411
|
-
*
|
|
412
|
-
* Problem: Angular's `(event-name)="handler($event)"` works on custom elements,
|
|
413
|
-
* but `$event` is the raw CustomEvent. The developer must write `$event.detail`
|
|
414
|
-
* to get the payload. This is verbose and error-prone.
|
|
415
|
-
*
|
|
416
|
-
* Solution: This directive listens for CustomEvents on the host element and
|
|
417
|
-
* re-emits them as Angular outputs with `$event = event.detail`.
|
|
418
|
-
*
|
|
419
|
-
* Usage:
|
|
420
|
-
* <wcc-counter wccEvent="count-changed" (wccEmit)="onCount($event)"></wcc-counter>
|
|
421
|
-
*
|
|
422
|
-
* Or for multiple events, use WccEvents (plural) with a comma-separated list:
|
|
423
|
-
* <wcc-counter wccEvents="count-changed, value-changed"
|
|
424
|
-
* (countChanged)="onCount($event)"
|
|
425
|
-
* (valueChanged)="onValue($event)">
|
|
426
|
-
* </wcc-counter>
|
|
427
|
-
*
|
|
428
|
-
* The event name is converted from kebab-case to camelCase for the output:
|
|
429
|
-
* 'count-changed' → (countChanged)
|
|
430
|
-
* 'value-changed' → (valueChanged)
|
|
431
|
-
* 'change' → (change)
|
|
432
|
-
*/
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* Single-event directive: listens for one CustomEvent and emits its detail.
|
|
436
|
-
*
|
|
437
|
-
* Usage:
|
|
438
|
-
* <wcc-counter wccEvent="count-changed" (wccEmit)="handler($event)"></wcc-counter>
|
|
439
|
-
*/
|
|
440
|
-
@Directive({
|
|
441
|
-
selector: '[wccEvent]',
|
|
442
|
-
standalone: true,
|
|
443
|
-
})
|
|
444
|
-
export class WccEvent implements OnInit, OnDestroy {
|
|
445
|
-
@Input() wccEvent = '';
|
|
446
|
-
@Output() wccEmit = new EventEmitter<any>();
|
|
447
|
-
|
|
448
|
-
private el = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
449
|
-
private listener: ((e: Event) => void) | null = null;
|
|
450
|
-
|
|
451
|
-
ngOnInit(): void {
|
|
452
|
-
if (!this.wccEvent) return;
|
|
453
|
-
this.listener = (e: Event) => {
|
|
454
|
-
this.wccEmit.emit((e as CustomEvent).detail);
|
|
455
|
-
};
|
|
456
|
-
this.el.nativeElement.addEventListener(this.wccEvent, this.listener);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
ngOnDestroy(): void {
|
|
460
|
-
if (this.listener && this.wccEvent) {
|
|
461
|
-
this.el.nativeElement.removeEventListener(this.wccEvent, this.listener);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/**
|
|
467
|
-
* Event bridging directive: allows using camelCase event bindings on WCC elements.
|
|
468
|
-
*
|
|
469
|
-
* Without this directive, Angular devs must use kebab-case event names:
|
|
470
|
-
* <wcc-counter (count-changed)="onCount($event.detail)"></wcc-counter>
|
|
471
|
-
*
|
|
472
|
-
* With this directive, they can use camelCase (more Angular-idiomatic):
|
|
473
|
-
* <wcc-counter wccEvents (countChanged)="onCount($event.detail)"></wcc-counter>
|
|
474
|
-
*
|
|
475
|
-
* The directive listens for kebab-case CustomEvents from the WCC component
|
|
476
|
-
* and re-dispatches them with camelCase names so Angular's event binding picks them up.
|
|
477
|
-
*
|
|
478
|
-
* Event name conversion:
|
|
479
|
-
* 'count-changed' → dispatches 'countChanged'
|
|
480
|
-
* 'value-changed' → dispatches 'valueChanged'
|
|
481
|
-
* 'change' → dispatches 'change' (no conversion needed)
|
|
482
|
-
*
|
|
483
|
-
* Event discovery:
|
|
484
|
-
* - Auto: reads `static __events` from the WCC component class (set by codegen)
|
|
485
|
-
* - Manual: pass an explicit array via [wccEvents]="['count-changed', 'value-changed']"
|
|
486
|
-
*
|
|
487
|
-
* Note: $event is still the CustomEvent — use $event.detail to get the payload.
|
|
488
|
-
* This is consistent with how Angular handles all DOM events.
|
|
489
|
-
*/
|
|
490
|
-
@Directive({
|
|
491
|
-
selector: '[wccEvents]',
|
|
492
|
-
standalone: true,
|
|
493
|
-
})
|
|
494
|
-
export class WccEvents implements OnInit, OnDestroy {
|
|
495
|
-
/** Optional explicit list of kebab-case event names to bridge */
|
|
496
|
-
@Input() wccEvents: string[] | '' = '';
|
|
497
|
-
|
|
498
|
-
private el = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
499
|
-
private listeners: Array<[string, (e: Event) => void]> = [];
|
|
500
|
-
|
|
501
|
-
ngOnInit(): void {
|
|
502
|
-
const hostEl = this.el.nativeElement;
|
|
503
|
-
const tagName = hostEl.tagName.toLowerCase();
|
|
504
|
-
if (!tagName.includes('-')) return;
|
|
505
|
-
|
|
506
|
-
this.setupEvents(hostEl, tagName);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
private async setupEvents(hostEl: HTMLElement, tagName: string): Promise<void> {
|
|
510
|
-
let eventNames: string[];
|
|
511
|
-
|
|
512
|
-
if (Array.isArray(this.wccEvents) && this.wccEvents.length > 0) {
|
|
513
|
-
eventNames = this.wccEvents;
|
|
514
|
-
} else {
|
|
515
|
-
// Auto-discover from component metadata
|
|
516
|
-
await customElements.whenDefined(tagName);
|
|
517
|
-
const ctor = customElements.get(tagName) as any;
|
|
518
|
-
eventNames = ctor?.__events || [];
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
if (eventNames.length === 0) return;
|
|
522
|
-
|
|
523
|
-
for (const eventName of eventNames) {
|
|
524
|
-
// Only bridge events that have hyphens (already camelCase events don't need bridging)
|
|
525
|
-
if (!eventName.includes('-')) continue;
|
|
526
|
-
|
|
527
|
-
const camelName = eventName.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
528
|
-
|
|
529
|
-
const listener = (e: Event) => {
|
|
530
|
-
// Re-dispatch with camelCase name — Angular's (camelName) binding will catch it
|
|
531
|
-
hostEl.dispatchEvent(new CustomEvent(camelName, {
|
|
532
|
-
detail: (e as CustomEvent).detail,
|
|
533
|
-
bubbles: false,
|
|
534
|
-
cancelable: false,
|
|
535
|
-
}));
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
hostEl.addEventListener(eventName, listener);
|
|
539
|
-
this.listeners.push([eventName, listener]);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
ngOnDestroy(): void {
|
|
544
|
-
const hostEl = this.el.nativeElement;
|
|
545
|
-
for (const [name, listener] of this.listeners) {
|
|
546
|
-
hostEl.removeEventListener(name, listener);
|
|
547
|
-
}
|
|
548
|
-
this.listeners = [];
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Angular adapter for WCC Scoped Slots and Event Binding.
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* - WccSlotDef: Auxiliary directive for ng-template[slot]
|
|
6
|
+
* - WccSlotsDirective: Main directive activated via [wccSlots] attribute
|
|
7
|
+
* - WccEvent: Single-event directive (wccEvent="name" + wccEmit output)
|
|
8
|
+
* - WccEvents: Multi-event bridging directive (kebab-case → camelCase)
|
|
9
|
+
* - WccModel: Two-way binding bridge for [(prop)] banana-box syntax
|
|
10
|
+
* - SlotContext: Interface for template context typing
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* import { WccSlotsDirective, WccSlotDef, WccEvent, WccEvents } from '@sprlab/wccompiler/adapters/angular';
|
|
14
|
+
*
|
|
15
|
+
* @Component({
|
|
16
|
+
* imports: [WccSlotsDirective, WccSlotDef, WccEvent, WccEvents],
|
|
17
|
+
* schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
18
|
+
* template: `
|
|
19
|
+
* <wcc-card wccSlots>
|
|
20
|
+
* <ng-template slot="header"><strong>Header</strong></ng-template>
|
|
21
|
+
* <ng-template slot="stats" let-likes>{{ likes }} likes</ng-template>
|
|
22
|
+
* </wcc-card>
|
|
23
|
+
*
|
|
24
|
+
* <!-- Event binding option 1: single event with unwrapped detail -->
|
|
25
|
+
* <wcc-counter wccEvent="count-changed" (wccEmit)="onCount($event)"></wcc-counter>
|
|
26
|
+
*
|
|
27
|
+
* <!-- Event binding option 2: camelCase event names -->
|
|
28
|
+
* <wcc-counter wccEvents (countChanged)="onCount($event.detail)"></wcc-counter>
|
|
29
|
+
*
|
|
30
|
+
* <!-- Event binding option 3: standard Angular (always works) -->
|
|
31
|
+
* <wcc-counter (count-changed)="onCount($event.detail)"></wcc-counter>
|
|
32
|
+
* `
|
|
33
|
+
* })
|
|
34
|
+
*
|
|
35
|
+
* Note: Add the `wccSlots` attribute to any WCC element that uses slots.
|
|
36
|
+
* This is required because Angular AOT cannot evaluate dynamic selectors.
|
|
37
|
+
*
|
|
38
|
+
* @module @sprlab/wccompiler/adapters/angular
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
Directive,
|
|
43
|
+
TemplateRef,
|
|
44
|
+
ElementRef,
|
|
45
|
+
ViewContainerRef,
|
|
46
|
+
ChangeDetectorRef,
|
|
47
|
+
ContentChildren,
|
|
48
|
+
QueryList,
|
|
49
|
+
EmbeddedViewRef,
|
|
50
|
+
AfterContentInit,
|
|
51
|
+
OnDestroy,
|
|
52
|
+
OnInit,
|
|
53
|
+
Output,
|
|
54
|
+
EventEmitter,
|
|
55
|
+
inject,
|
|
56
|
+
Attribute,
|
|
57
|
+
Input,
|
|
58
|
+
} from '@angular/core';
|
|
59
|
+
|
|
60
|
+
// ─── Interfaces ─────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/** Context object passed to createEmbeddedView for scoped slots */
|
|
63
|
+
export interface SlotContext {
|
|
64
|
+
$implicit: any;
|
|
65
|
+
[key: string]: any;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type SlotType = 'named' | 'scoped';
|
|
69
|
+
|
|
70
|
+
interface SlotState {
|
|
71
|
+
type: SlotType;
|
|
72
|
+
slotDef: WccSlotDef;
|
|
73
|
+
viewRef: EmbeddedViewRef<SlotContext> | null;
|
|
74
|
+
cleanup: (() => void) | null;
|
|
75
|
+
wrapperEl: HTMLElement | null;
|
|
76
|
+
context: SlotContext | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── WccSlotDef — Auxiliary Directive ───────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Auxiliary directive that marks an ng-template as slot content.
|
|
83
|
+
* Captures the TemplateRef and the slot name from the HTML 'slot' attribute.
|
|
84
|
+
*
|
|
85
|
+
* Usage:
|
|
86
|
+
* <ng-template slot="header">...</ng-template>
|
|
87
|
+
* <ng-template slot="stats" let-likes>{{likes}}</ng-template>
|
|
88
|
+
*/
|
|
89
|
+
@Directive({
|
|
90
|
+
selector: 'ng-template[slot]',
|
|
91
|
+
standalone: true,
|
|
92
|
+
})
|
|
93
|
+
export class WccSlotDef {
|
|
94
|
+
public readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
|
|
95
|
+
public readonly slotName: string;
|
|
96
|
+
|
|
97
|
+
constructor(@Attribute('slot') name: string | null) {
|
|
98
|
+
this.slotName = name || '';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── WccSlotsDirective — Main Directive ─────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Main directive that activates on elements with the [wccSlots] attribute.
|
|
106
|
+
* Classifies ng-template[slot] children as named or scoped slots and manages
|
|
107
|
+
* their lifecycle.
|
|
108
|
+
*
|
|
109
|
+
* Uses a simple attribute selector `[wccSlots]` instead of a dynamic exclusion
|
|
110
|
+
* selector, because Angular AOT cannot evaluate computed selector expressions.
|
|
111
|
+
*/
|
|
112
|
+
@Directive({
|
|
113
|
+
selector: '[wccSlots]',
|
|
114
|
+
standalone: true,
|
|
115
|
+
})
|
|
116
|
+
export class WccSlotsDirective implements AfterContentInit, OnDestroy {
|
|
117
|
+
@ContentChildren(WccSlotDef) slotDefs!: QueryList<WccSlotDef>;
|
|
118
|
+
|
|
119
|
+
private el = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
120
|
+
private vcr = inject(ViewContainerRef);
|
|
121
|
+
private cdr = inject(ChangeDetectorRef);
|
|
122
|
+
|
|
123
|
+
private slots = new Map<string, SlotState>();
|
|
124
|
+
private eventCleanups: (() => void)[] = [];
|
|
125
|
+
private destroyed = false;
|
|
126
|
+
|
|
127
|
+
ngAfterContentInit(): void {
|
|
128
|
+
// Runtime guard: only proceed for custom elements (tag name contains hyphen)
|
|
129
|
+
if (!this.el.nativeElement.tagName.toLowerCase().includes('-')) return;
|
|
130
|
+
|
|
131
|
+
// Normalize Angular-style slot attributes: slot-header → slot="header"
|
|
132
|
+
this.normalizeSlotAttributes();
|
|
133
|
+
|
|
134
|
+
this.classifyAndInitSlots();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
ngOnDestroy(): void {
|
|
138
|
+
this.destroyed = true;
|
|
139
|
+
this.cleanup();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Slot Attribute Normalization ───────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Normalizes Angular-style slot attributes to standard HTML slot attributes.
|
|
146
|
+
* Converts: <div slot-header> → <div slot="header">
|
|
147
|
+
*
|
|
148
|
+
* This enables the Angular ng-content select pattern:
|
|
149
|
+
* <wcc-card wccSlots>
|
|
150
|
+
* <nav slot-header>Title</nav>
|
|
151
|
+
* <span slot-footer>Footer</span>
|
|
152
|
+
* </wcc-card>
|
|
153
|
+
*
|
|
154
|
+
* Skips reserved prefixes: slot-props, slot-template-*
|
|
155
|
+
*/
|
|
156
|
+
private normalizeSlotAttributes(): void {
|
|
157
|
+
const hostEl = this.el.nativeElement;
|
|
158
|
+
for (const child of Array.from(hostEl.children)) {
|
|
159
|
+
for (const attr of Array.from(child.attributes)) {
|
|
160
|
+
if (
|
|
161
|
+
attr.name.startsWith('slot-') &&
|
|
162
|
+
!attr.value &&
|
|
163
|
+
attr.name !== 'slot-props' &&
|
|
164
|
+
!attr.name.startsWith('slot-template-')
|
|
165
|
+
) {
|
|
166
|
+
const slotName = attr.name.slice(5); // "slot-header" → "header"
|
|
167
|
+
child.removeAttribute(attr.name);
|
|
168
|
+
child.setAttribute('slot', slotName);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Classification ─────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/** Classifies slots using __scopedSlots from the host element and initializes them */
|
|
177
|
+
private async classifyAndInitSlots(): Promise<void> {
|
|
178
|
+
const hostEl = this.el.nativeElement;
|
|
179
|
+
const tagName = hostEl.tagName.toLowerCase();
|
|
180
|
+
|
|
181
|
+
// Wait for the custom element to be defined (ensures the class is upgraded)
|
|
182
|
+
await customElements.whenDefined(tagName);
|
|
183
|
+
if (this.destroyed) return;
|
|
184
|
+
|
|
185
|
+
const element = hostEl as any;
|
|
186
|
+
// Read from instance getter or static property
|
|
187
|
+
const scopedNames: string[] = element.__scopedSlots
|
|
188
|
+
|| (element.constructor && element.constructor.__scopedSlots)
|
|
189
|
+
|| [];
|
|
190
|
+
|
|
191
|
+
for (const slotDef of this.slotDefs) {
|
|
192
|
+
if (!slotDef.slotName) continue;
|
|
193
|
+
|
|
194
|
+
if (scopedNames.includes(slotDef.slotName)) {
|
|
195
|
+
this.initScopedSlot(slotDef);
|
|
196
|
+
} else {
|
|
197
|
+
this.initNamedSlot(slotDef);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Named Slot ─────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/** Named Slot: immediate static rendering */
|
|
205
|
+
private initNamedSlot(slotDef: WccSlotDef): void {
|
|
206
|
+
const hostEl = this.el.nativeElement;
|
|
207
|
+
|
|
208
|
+
// Strategy 1: Find [data-slot] container inside the component's internal DOM
|
|
209
|
+
const dataSlotEl = hostEl.querySelector(`[data-slot="${slotDef.slotName}"]`);
|
|
210
|
+
let wrapper: HTMLElement;
|
|
211
|
+
|
|
212
|
+
if (dataSlotEl) {
|
|
213
|
+
// Use the data-slot element directly — clear fallback content and insert rendered nodes
|
|
214
|
+
wrapper = dataSlotEl as HTMLElement;
|
|
215
|
+
wrapper.innerHTML = '';
|
|
216
|
+
} else {
|
|
217
|
+
// Strategy 2: Fallback for Shadow DOM / native <slot> elements
|
|
218
|
+
wrapper = document.createElement('div');
|
|
219
|
+
wrapper.setAttribute('slot', slotDef.slotName);
|
|
220
|
+
wrapper.style.display = 'contents';
|
|
221
|
+
hostEl.appendChild(wrapper);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const viewRef = this.vcr.createEmbeddedView(slotDef.templateRef);
|
|
225
|
+
for (const node of viewRef.rootNodes) {
|
|
226
|
+
wrapper.appendChild(node);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.slots.set(slotDef.slotName, {
|
|
230
|
+
type: 'named',
|
|
231
|
+
slotDef,
|
|
232
|
+
viewRef,
|
|
233
|
+
cleanup: null,
|
|
234
|
+
wrapperEl: wrapper,
|
|
235
|
+
context: null,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
this.cdr.detectChanges();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Scoped Slot ────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/** Scoped Slot: registration + reactive rendering */
|
|
244
|
+
private initScopedSlot(slotDef: WccSlotDef): void {
|
|
245
|
+
const hostEl = this.el.nativeElement;
|
|
246
|
+
|
|
247
|
+
const state: SlotState = {
|
|
248
|
+
type: 'scoped',
|
|
249
|
+
slotDef,
|
|
250
|
+
viewRef: null,
|
|
251
|
+
cleanup: null,
|
|
252
|
+
wrapperEl: null,
|
|
253
|
+
context: null,
|
|
254
|
+
};
|
|
255
|
+
this.slots.set(slotDef.slotName, state);
|
|
256
|
+
|
|
257
|
+
// Register renderer
|
|
258
|
+
const element = hostEl as any;
|
|
259
|
+
if (typeof element.registerSlotRenderer === 'function') {
|
|
260
|
+
state.cleanup = element.registerSlotRenderer(
|
|
261
|
+
slotDef.slotName,
|
|
262
|
+
(props: Record<string, any>) => this.renderSlot(slotDef.slotName, props)
|
|
263
|
+
);
|
|
264
|
+
} else {
|
|
265
|
+
// Fallback: listen for wcc:slot-update event
|
|
266
|
+
const handler = (e: CustomEvent) => {
|
|
267
|
+
if (e.detail?.slot === slotDef.slotName) {
|
|
268
|
+
this.renderSlot(slotDef.slotName, e.detail.props);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
hostEl.addEventListener('wcc:slot-update', handler as EventListener);
|
|
272
|
+
this.eventCleanups.push(() =>
|
|
273
|
+
hostEl.removeEventListener('wcc:slot-update', handler as EventListener)
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── Context Construction ───────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Builds the Angular context for createEmbeddedView.
|
|
282
|
+
*
|
|
283
|
+
* Rules:
|
|
284
|
+
* - 0 props: $implicit = undefined
|
|
285
|
+
* - 1 prop: $implicit = that single value, plus the named prop key
|
|
286
|
+
* - N props (N > 1): $implicit = full props object, plus all named props
|
|
287
|
+
*/
|
|
288
|
+
buildContext(props: Record<string, any>): SlotContext {
|
|
289
|
+
const keys = Object.keys(props);
|
|
290
|
+
if (keys.length === 0) {
|
|
291
|
+
return { $implicit: undefined };
|
|
292
|
+
}
|
|
293
|
+
if (keys.length === 1) {
|
|
294
|
+
return { $implicit: props[keys[0]], ...props };
|
|
295
|
+
}
|
|
296
|
+
return { $implicit: props, ...props };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── Render Slot ────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
/** Creates or updates the EmbeddedView of a scoped slot */
|
|
302
|
+
private renderSlot(slotName: string, props: Record<string, any> | null): void {
|
|
303
|
+
const state = this.slots.get(slotName);
|
|
304
|
+
if (!state || this.destroyed) return;
|
|
305
|
+
|
|
306
|
+
if (props == null) {
|
|
307
|
+
if (state.viewRef) {
|
|
308
|
+
state.viewRef.destroy();
|
|
309
|
+
state.viewRef = null;
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const context = this.buildContext(props);
|
|
315
|
+
state.context = context;
|
|
316
|
+
|
|
317
|
+
if (state.viewRef) {
|
|
318
|
+
// Update existing view context
|
|
319
|
+
Object.assign(state.viewRef.context, context);
|
|
320
|
+
state.viewRef.markForCheck();
|
|
321
|
+
// Re-insert nodes to reflect updated content (Angular doesn't auto-update DOM for detached views)
|
|
322
|
+
if (state.wrapperEl) {
|
|
323
|
+
state.wrapperEl.innerHTML = '';
|
|
324
|
+
for (const node of state.viewRef.rootNodes) {
|
|
325
|
+
state.wrapperEl.appendChild(node);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
state.viewRef = this.vcr.createEmbeddedView(state.slotDef.templateRef, context);
|
|
330
|
+
this.insertView(slotName, state);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
this.cdr.detectChanges();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ─── DOM Insertion ──────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Inserts view root nodes into the custom element's DOM.
|
|
340
|
+
*
|
|
341
|
+
* Strategy:
|
|
342
|
+
* 1. Look for a [data-slot="slotName"] element inside the component (non-Shadow DOM)
|
|
343
|
+
* → clear its content and insert the rendered nodes there
|
|
344
|
+
* 2. Fallback: append a wrapper <div slot="slotName"> to the host (Shadow DOM / native slots)
|
|
345
|
+
*/
|
|
346
|
+
private insertView(slotName: string, state: SlotState): void {
|
|
347
|
+
if (!state.viewRef) return;
|
|
348
|
+
const hostEl = this.el.nativeElement;
|
|
349
|
+
|
|
350
|
+
// Strategy 1: Find [data-slot] container inside the component's internal DOM
|
|
351
|
+
const dataSlotEl = hostEl.querySelector(`[data-slot="${slotName}"]`);
|
|
352
|
+
if (dataSlotEl) {
|
|
353
|
+
// Use the data-slot element as the wrapper (no extra div needed)
|
|
354
|
+
state.wrapperEl = dataSlotEl as HTMLElement;
|
|
355
|
+
state.wrapperEl.innerHTML = '';
|
|
356
|
+
for (const node of state.viewRef.rootNodes) {
|
|
357
|
+
state.wrapperEl.appendChild(node);
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Strategy 2: Fallback for Shadow DOM / native <slot> elements
|
|
363
|
+
if (!state.wrapperEl) {
|
|
364
|
+
state.wrapperEl = document.createElement('div');
|
|
365
|
+
state.wrapperEl.setAttribute('slot', slotName);
|
|
366
|
+
state.wrapperEl.style.display = 'contents';
|
|
367
|
+
hostEl.appendChild(state.wrapperEl);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
state.wrapperEl.innerHTML = '';
|
|
371
|
+
for (const node of state.viewRef.rootNodes) {
|
|
372
|
+
state.wrapperEl.appendChild(node);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ─── Cleanup ────────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
/** Full cleanup on destroy */
|
|
379
|
+
private cleanup(): void {
|
|
380
|
+
for (const [, state] of this.slots) {
|
|
381
|
+
if (state.viewRef) {
|
|
382
|
+
state.viewRef.destroy();
|
|
383
|
+
}
|
|
384
|
+
if (state.cleanup) {
|
|
385
|
+
state.cleanup();
|
|
386
|
+
}
|
|
387
|
+
if (state.wrapperEl) {
|
|
388
|
+
// If the wrapper is a [data-slot] element (part of the component's internal DOM),
|
|
389
|
+
// just clear its content rather than removing it from the DOM
|
|
390
|
+
if (state.wrapperEl.hasAttribute('data-slot')) {
|
|
391
|
+
state.wrapperEl.innerHTML = '';
|
|
392
|
+
} else if (state.wrapperEl.parentNode) {
|
|
393
|
+
state.wrapperEl.parentNode.removeChild(state.wrapperEl);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
this.slots.clear();
|
|
398
|
+
|
|
399
|
+
for (const fn of this.eventCleanups) {
|
|
400
|
+
fn();
|
|
401
|
+
}
|
|
402
|
+
this.eventCleanups = [];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
// ─── WccEvent — Event Binding Directive ─────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Directive that bridges WCC custom element events to Angular output bindings.
|
|
411
|
+
*
|
|
412
|
+
* Problem: Angular's `(event-name)="handler($event)"` works on custom elements,
|
|
413
|
+
* but `$event` is the raw CustomEvent. The developer must write `$event.detail`
|
|
414
|
+
* to get the payload. This is verbose and error-prone.
|
|
415
|
+
*
|
|
416
|
+
* Solution: This directive listens for CustomEvents on the host element and
|
|
417
|
+
* re-emits them as Angular outputs with `$event = event.detail`.
|
|
418
|
+
*
|
|
419
|
+
* Usage:
|
|
420
|
+
* <wcc-counter wccEvent="count-changed" (wccEmit)="onCount($event)"></wcc-counter>
|
|
421
|
+
*
|
|
422
|
+
* Or for multiple events, use WccEvents (plural) with a comma-separated list:
|
|
423
|
+
* <wcc-counter wccEvents="count-changed, value-changed"
|
|
424
|
+
* (countChanged)="onCount($event)"
|
|
425
|
+
* (valueChanged)="onValue($event)">
|
|
426
|
+
* </wcc-counter>
|
|
427
|
+
*
|
|
428
|
+
* The event name is converted from kebab-case to camelCase for the output:
|
|
429
|
+
* 'count-changed' → (countChanged)
|
|
430
|
+
* 'value-changed' → (valueChanged)
|
|
431
|
+
* 'change' → (change)
|
|
432
|
+
*/
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Single-event directive: listens for one CustomEvent and emits its detail.
|
|
436
|
+
*
|
|
437
|
+
* Usage:
|
|
438
|
+
* <wcc-counter wccEvent="count-changed" (wccEmit)="handler($event)"></wcc-counter>
|
|
439
|
+
*/
|
|
440
|
+
@Directive({
|
|
441
|
+
selector: '[wccEvent]',
|
|
442
|
+
standalone: true,
|
|
443
|
+
})
|
|
444
|
+
export class WccEvent implements OnInit, OnDestroy {
|
|
445
|
+
@Input() wccEvent = '';
|
|
446
|
+
@Output() wccEmit = new EventEmitter<any>();
|
|
447
|
+
|
|
448
|
+
private el = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
449
|
+
private listener: ((e: Event) => void) | null = null;
|
|
450
|
+
|
|
451
|
+
ngOnInit(): void {
|
|
452
|
+
if (!this.wccEvent) return;
|
|
453
|
+
this.listener = (e: Event) => {
|
|
454
|
+
this.wccEmit.emit((e as CustomEvent).detail);
|
|
455
|
+
};
|
|
456
|
+
this.el.nativeElement.addEventListener(this.wccEvent, this.listener);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
ngOnDestroy(): void {
|
|
460
|
+
if (this.listener && this.wccEvent) {
|
|
461
|
+
this.el.nativeElement.removeEventListener(this.wccEvent, this.listener);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Event bridging directive: allows using camelCase event bindings on WCC elements.
|
|
468
|
+
*
|
|
469
|
+
* Without this directive, Angular devs must use kebab-case event names:
|
|
470
|
+
* <wcc-counter (count-changed)="onCount($event.detail)"></wcc-counter>
|
|
471
|
+
*
|
|
472
|
+
* With this directive, they can use camelCase (more Angular-idiomatic):
|
|
473
|
+
* <wcc-counter wccEvents (countChanged)="onCount($event.detail)"></wcc-counter>
|
|
474
|
+
*
|
|
475
|
+
* The directive listens for kebab-case CustomEvents from the WCC component
|
|
476
|
+
* and re-dispatches them with camelCase names so Angular's event binding picks them up.
|
|
477
|
+
*
|
|
478
|
+
* Event name conversion:
|
|
479
|
+
* 'count-changed' → dispatches 'countChanged'
|
|
480
|
+
* 'value-changed' → dispatches 'valueChanged'
|
|
481
|
+
* 'change' → dispatches 'change' (no conversion needed)
|
|
482
|
+
*
|
|
483
|
+
* Event discovery:
|
|
484
|
+
* - Auto: reads `static __events` from the WCC component class (set by codegen)
|
|
485
|
+
* - Manual: pass an explicit array via [wccEvents]="['count-changed', 'value-changed']"
|
|
486
|
+
*
|
|
487
|
+
* Note: $event is still the CustomEvent — use $event.detail to get the payload.
|
|
488
|
+
* This is consistent with how Angular handles all DOM events.
|
|
489
|
+
*/
|
|
490
|
+
@Directive({
|
|
491
|
+
selector: '[wccEvents]',
|
|
492
|
+
standalone: true,
|
|
493
|
+
})
|
|
494
|
+
export class WccEvents implements OnInit, OnDestroy {
|
|
495
|
+
/** Optional explicit list of kebab-case event names to bridge */
|
|
496
|
+
@Input() wccEvents: string[] | '' = '';
|
|
497
|
+
|
|
498
|
+
private el = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
499
|
+
private listeners: Array<[string, (e: Event) => void]> = [];
|
|
500
|
+
|
|
501
|
+
ngOnInit(): void {
|
|
502
|
+
const hostEl = this.el.nativeElement;
|
|
503
|
+
const tagName = hostEl.tagName.toLowerCase();
|
|
504
|
+
if (!tagName.includes('-')) return;
|
|
505
|
+
|
|
506
|
+
this.setupEvents(hostEl, tagName);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private async setupEvents(hostEl: HTMLElement, tagName: string): Promise<void> {
|
|
510
|
+
let eventNames: string[];
|
|
511
|
+
|
|
512
|
+
if (Array.isArray(this.wccEvents) && this.wccEvents.length > 0) {
|
|
513
|
+
eventNames = this.wccEvents;
|
|
514
|
+
} else {
|
|
515
|
+
// Auto-discover from component metadata
|
|
516
|
+
await customElements.whenDefined(tagName);
|
|
517
|
+
const ctor = customElements.get(tagName) as any;
|
|
518
|
+
eventNames = ctor?.__events || [];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (eventNames.length === 0) return;
|
|
522
|
+
|
|
523
|
+
for (const eventName of eventNames) {
|
|
524
|
+
// Only bridge events that have hyphens (already camelCase events don't need bridging)
|
|
525
|
+
if (!eventName.includes('-')) continue;
|
|
526
|
+
|
|
527
|
+
const camelName = eventName.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
528
|
+
|
|
529
|
+
const listener = (e: Event) => {
|
|
530
|
+
// Re-dispatch with camelCase name — Angular's (camelName) binding will catch it
|
|
531
|
+
hostEl.dispatchEvent(new CustomEvent(camelName, {
|
|
532
|
+
detail: (e as CustomEvent).detail,
|
|
533
|
+
bubbles: false,
|
|
534
|
+
cancelable: false,
|
|
535
|
+
}));
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
hostEl.addEventListener(eventName, listener);
|
|
539
|
+
this.listeners.push([eventName, listener]);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
ngOnDestroy(): void {
|
|
544
|
+
const hostEl = this.el.nativeElement;
|
|
545
|
+
for (const [name, listener] of this.listeners) {
|
|
546
|
+
hostEl.removeEventListener(name, listener);
|
|
547
|
+
}
|
|
548
|
+
this.listeners = [];
|
|
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
|
+
}
|