@sprlab/wccompiler 0.10.4 → 0.10.6
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 +126 -36
- package/adapters/react.js +34 -6
- package/integrations/vue.js +9 -5
- package/lib/codegen.js +7 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -192,6 +192,11 @@ watch(count, (newVal, oldVal) => {
|
|
|
192
192
|
watch(() => props.count, (newVal, oldVal) => {
|
|
193
193
|
console.log(`Prop changed: ${oldVal} → ${newVal}`)
|
|
194
194
|
})
|
|
195
|
+
|
|
196
|
+
// Watch a derived expression
|
|
197
|
+
watch(() => count() * 2, (newVal, oldVal) => {
|
|
198
|
+
console.log(`Doubled changed: ${oldVal} → ${newVal}`)
|
|
199
|
+
})
|
|
195
200
|
```
|
|
196
201
|
|
|
197
202
|
`watch` observes a specific signal or getter and provides both old and new values. The callback does not run on initial mount — only on subsequent changes.
|
|
@@ -248,6 +253,58 @@ const emit = defineEmits<{ (e: 'change', value: number): void }>()
|
|
|
248
253
|
|
|
249
254
|
The compiler validates emit calls against declared events at compile time.
|
|
250
255
|
|
|
256
|
+
## defineModel (Two-Way Binding)
|
|
257
|
+
|
|
258
|
+
`defineModel` declares a prop that supports two-way binding across frameworks:
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
import { defineModel } from 'wcc'
|
|
262
|
+
|
|
263
|
+
const count = defineModel({ name: 'count', default: 0 })
|
|
264
|
+
const title = defineModel({ name: 'title', default: 'untitled' })
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Read and write like a signal:
|
|
268
|
+
```js
|
|
269
|
+
count() // read current value
|
|
270
|
+
count.set(5) // write — updates internal state + emits events
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Events emitted on write:**
|
|
274
|
+
|
|
275
|
+
| Event | Purpose |
|
|
276
|
+
|-------|---------|
|
|
277
|
+
| `count-changed` | Kebab-case — for Vue plugin, addEventListener |
|
|
278
|
+
| `countChanged` | camelCase — for Angular, direct binding |
|
|
279
|
+
| `countChange` | Angular `[(count)]` banana-box syntax |
|
|
280
|
+
| `wcc:model` | Generic — for vanilla JS and WCC-to-WCC |
|
|
281
|
+
|
|
282
|
+
**Usage per framework:**
|
|
283
|
+
|
|
284
|
+
```vue
|
|
285
|
+
<!-- Vue (with plugin) -->
|
|
286
|
+
<wcc-counter v-model:count="ref"></wcc-counter>
|
|
287
|
+
|
|
288
|
+
<!-- Vue (without plugin) -->
|
|
289
|
+
<wcc-counter :count="ref" @count-changed="ref = $event.detail"></wcc-counter>
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
```jsx
|
|
293
|
+
// React (with wrapper)
|
|
294
|
+
<WccCounter count={count} onCountChange={(value) => setCount(value)} />
|
|
295
|
+
|
|
296
|
+
// React (native CE)
|
|
297
|
+
<wcc-counter count={count} oncountchanged={(e) => setCount(e.detail)} />
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
```html
|
|
301
|
+
<!-- Angular (zero-config two-way) -->
|
|
302
|
+
<wcc-counter [(count)]="signal"></wcc-counter>
|
|
303
|
+
|
|
304
|
+
<!-- Angular (manual) -->
|
|
305
|
+
<wcc-counter [count]="signal()" (countChange)="signal.set($event.detail)"></wcc-counter>
|
|
306
|
+
```
|
|
307
|
+
|
|
251
308
|
## Template Directives
|
|
252
309
|
|
|
253
310
|
### Text Interpolation
|
|
@@ -638,63 +695,96 @@ Each standalone component has its own isolated reactive runtime. Signals from co
|
|
|
638
695
|
|
|
639
696
|
## Framework Integrations
|
|
640
697
|
|
|
641
|
-
WCC components are native custom elements — they work in any framework.
|
|
698
|
+
WCC components are native custom elements — they work in any framework. Props, events, and named slots work natively with zero WCC-specific config. Two-way binding is zero-config in Angular; Vue and React require a lightweight plugin. Scoped slots require a framework plugin for idiomatic syntax.
|
|
642
699
|
|
|
643
|
-
###
|
|
700
|
+
### Zero-Config (works immediately)
|
|
701
|
+
|
|
702
|
+
Just import the compiled component and use it:
|
|
703
|
+
|
|
704
|
+
```html
|
|
705
|
+
<script type="module" src="dist/wcc-counter.js"></script>
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
**Vue:**
|
|
709
|
+
```vue
|
|
710
|
+
<wcc-counter :count="ref" @count-changed="handler($event.detail)">
|
|
711
|
+
<div slot="footer">Footer content</div>
|
|
712
|
+
</wcc-counter>
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
**React 19:**
|
|
716
|
+
```jsx
|
|
717
|
+
<wcc-counter count={state} onCountchanged={(e) => handler(e.detail)}>
|
|
718
|
+
<div slot="footer">Footer content</div>
|
|
719
|
+
</wcc-counter>
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
**Angular:**
|
|
723
|
+
```html
|
|
724
|
+
<wcc-counter [count]="signal()" (count-changed)="handler($event.detail)" [(count)]="signal">
|
|
725
|
+
<div slot="footer">Footer content</div>
|
|
726
|
+
</wcc-counter>
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
### Vue Plugin (v-model, modifiers, scoped slots)
|
|
644
730
|
|
|
645
731
|
```js
|
|
646
732
|
// vite.config.js
|
|
647
733
|
import { wccVuePlugin } from '@sprlab/wccompiler/integrations/vue'
|
|
734
|
+
export default defineConfig({ plugins: [wccVuePlugin()] })
|
|
735
|
+
```
|
|
648
736
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
737
|
+
```vue
|
|
738
|
+
<wcc-input v-model="text" v-model:count.number="count"></wcc-input>
|
|
739
|
+
<wcc-card>
|
|
740
|
+
<template #header><strong>Title</strong></template>
|
|
741
|
+
<template #stats="{ likes }">{{ likes }} likes</template>
|
|
742
|
+
</wcc-card>
|
|
655
743
|
```
|
|
656
744
|
|
|
657
|
-
|
|
745
|
+
The plugin is needed for: v-model:prop (Vue assigns raw Event, not detail), v-model modifiers (.trim, .number), and scoped slot syntax (`{{prop}}` → `{%prop%}` escape).
|
|
746
|
+
|
|
747
|
+
Without the plugin, use `:prop` + `@prop-changed` for two-way binding manually.
|
|
748
|
+
|
|
749
|
+
### React Plugin + Wrappers (scoped slots, typed events, two-way)
|
|
658
750
|
|
|
659
|
-
|
|
751
|
+
```js
|
|
752
|
+
// vite.config.js
|
|
753
|
+
import { wccReactPlugin } from '@sprlab/wccompiler/integrations/react'
|
|
754
|
+
import react from '@vitejs/plugin-react'
|
|
755
|
+
export default defineConfig({ plugins: [wccReactPlugin(), react()] })
|
|
756
|
+
```
|
|
660
757
|
|
|
661
758
|
```jsx
|
|
662
|
-
|
|
663
|
-
import {
|
|
664
|
-
|
|
665
|
-
function App() {
|
|
666
|
-
// Form 1: Pass an existing ref
|
|
667
|
-
const counterRef = useRef(null)
|
|
668
|
-
useWccEvent(counterRef, 'change', (e) => console.log(e.detail))
|
|
669
|
-
return <wcc-counter ref={counterRef}></wcc-counter>
|
|
670
|
-
}
|
|
759
|
+
// Auto-generated wrappers — events unwrapped, React-idiomatic naming
|
|
760
|
+
import { createWccWrappers } from '@sprlab/wccompiler/adapters/react'
|
|
761
|
+
const { WccCounter, WccCard } = createWccWrappers()
|
|
671
762
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
}
|
|
763
|
+
<WccCounter count={count} onCountChange={(value) => setCount(value)} />
|
|
764
|
+
<WccCard renderStats={(likes) => <span>{likes} likes</span>}>
|
|
765
|
+
<p>Body</p>
|
|
766
|
+
</WccCard>
|
|
677
767
|
```
|
|
678
768
|
|
|
679
|
-
|
|
769
|
+
Wrappers read component metadata (`static __meta`) automatically — no manual event/model config needed.
|
|
770
|
+
|
|
771
|
+
### Angular Directive (scoped slots only)
|
|
680
772
|
|
|
681
|
-
|
|
773
|
+
Angular needs no plugin for props, events, or two-way binding. The directive is only needed for scoped slots:
|
|
682
774
|
|
|
683
775
|
```ts
|
|
684
|
-
import {
|
|
776
|
+
import { WccSlotsDirective, WccSlotDef } from '@sprlab/wccompiler/adapters/angular'
|
|
685
777
|
|
|
686
|
-
// Standalone component (Angular 17+)
|
|
687
778
|
@Component({
|
|
779
|
+
imports: [WccSlotsDirective, WccSlotDef],
|
|
688
780
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
689
|
-
template:
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
781
|
+
template: `
|
|
782
|
+
<wcc-card wccSlots>
|
|
783
|
+
<ng-template slot="header"><strong>Title</strong></ng-template>
|
|
784
|
+
<ng-template slot="stats" let-likes>{{ likes }} likes</ng-template>
|
|
785
|
+
</wcc-card>
|
|
786
|
+
`
|
|
696
787
|
})
|
|
697
|
-
export class AppModule {}
|
|
698
788
|
```
|
|
699
789
|
|
|
700
790
|
### Vanilla
|
package/adapters/react.js
CHANGED
|
@@ -143,12 +143,15 @@ function toReactEventProp(eventName) {
|
|
|
143
143
|
* Event names are converted: 'count-changed' → onCountChange prop (React convention)
|
|
144
144
|
* @param {string[]} [config.models] - Model prop names for two-way binding
|
|
145
145
|
* Each model 'name' creates: `name` prop (sets attribute) + `onNameChange` event
|
|
146
|
-
* @
|
|
146
|
+
* @param {string[]} [config.slots] - Named slot names for compound sub-components
|
|
147
|
+
* Each slot 'name' creates a `.Name` sub-component that renders `<div slot="name">`
|
|
148
|
+
* @returns {import('react').ForwardRefExoticComponent} A React component with compound sub-components
|
|
147
149
|
*
|
|
148
150
|
* @example
|
|
149
151
|
* const WccCounter = createWccWrapper('wcc-counter', {
|
|
150
152
|
* events: ['change'],
|
|
151
|
-
* models: ['count']
|
|
153
|
+
* models: ['count'],
|
|
154
|
+
* slots: ['header', 'footer']
|
|
152
155
|
* })
|
|
153
156
|
*
|
|
154
157
|
* function App() {
|
|
@@ -160,13 +163,15 @@ function toReactEventProp(eventName) {
|
|
|
160
163
|
* onChange={(value) => console.log('changed', value)}
|
|
161
164
|
* label="Clicks"
|
|
162
165
|
* >
|
|
163
|
-
* <
|
|
166
|
+
* <WccCounter.Header><strong>Title</strong></WccCounter.Header>
|
|
167
|
+
* <p>Body content</p>
|
|
168
|
+
* <WccCounter.Footer>Footer text</WccCounter.Footer>
|
|
164
169
|
* </WccCounter>
|
|
165
170
|
* )
|
|
166
171
|
* }
|
|
167
172
|
*/
|
|
168
173
|
export function createWccWrapper(tagName, config = {}) {
|
|
169
|
-
const { events = [], models = [] } = config
|
|
174
|
+
const { events = [], models = [], slots = [] } = config
|
|
170
175
|
|
|
171
176
|
// Build a set of event prop names for quick lookup
|
|
172
177
|
// Convention: kebab-case event → React onCamelCase (without trailing 'd' from 'changed')
|
|
@@ -270,6 +275,24 @@ export function createWccWrapper(tagName, config = {}) {
|
|
|
270
275
|
|
|
271
276
|
WccWrapper.displayName = tagName.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('')
|
|
272
277
|
|
|
278
|
+
// Compound components: generate .SlotName sub-components for each named slot
|
|
279
|
+
// This enables the pattern: <WccLayout.Header>content</WccLayout.Header>
|
|
280
|
+
// which renders as: <div slot="header">content</div>
|
|
281
|
+
for (const slotName of slots) {
|
|
282
|
+
if (!slotName) continue // skip default slot
|
|
283
|
+
const pascalSlot = slotName[0].toUpperCase() + slotName.slice(1)
|
|
284
|
+
const SlotComponent = function WccSlot({ children, ...rest }) {
|
|
285
|
+
const slotProps = { slot: slotName, style: { display: 'contents' } }
|
|
286
|
+
// Pass through any extra props as attributes on the wrapper div
|
|
287
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
288
|
+
slotProps[key] = value
|
|
289
|
+
}
|
|
290
|
+
return React.createElement('div', slotProps, children)
|
|
291
|
+
}
|
|
292
|
+
SlotComponent.displayName = `${WccWrapper.displayName}.${pascalSlot}`
|
|
293
|
+
WccWrapper[pascalSlot] = SlotComponent
|
|
294
|
+
}
|
|
295
|
+
|
|
273
296
|
return WccWrapper
|
|
274
297
|
}
|
|
275
298
|
|
|
@@ -304,6 +327,7 @@ export function wrapWccComponent(WccClass) {
|
|
|
304
327
|
return createWccWrapper(meta.tag, {
|
|
305
328
|
events: meta.events || [],
|
|
306
329
|
models: meta.models || [],
|
|
330
|
+
slots: meta.slots || [],
|
|
307
331
|
})
|
|
308
332
|
}
|
|
309
333
|
|
|
@@ -326,8 +350,12 @@ export function wrapWccComponent(WccClass) {
|
|
|
326
350
|
* export const { WccCounter, WccCard } = createWccWrappers()
|
|
327
351
|
*
|
|
328
352
|
* // Then use anywhere:
|
|
329
|
-
* <WccCounter count={count}
|
|
330
|
-
* <WccCard
|
|
353
|
+
* <WccCounter count={count} onCountChange={setCount} />
|
|
354
|
+
* <WccCard>
|
|
355
|
+
* <WccCard.Header><strong>Title</strong></WccCard.Header>
|
|
356
|
+
* <p>Body</p>
|
|
357
|
+
* <WccCard.Footer>Footer</WccCard.Footer>
|
|
358
|
+
* </WccCard>
|
|
331
359
|
*/
|
|
332
360
|
export function createWccWrappers(options = {}) {
|
|
333
361
|
const { prefix = 'wcc-' } = options
|
package/integrations/vue.js
CHANGED
|
@@ -65,11 +65,15 @@ export function wccVuePlugin(options = {}) {
|
|
|
65
65
|
|
|
66
66
|
let result = code
|
|
67
67
|
|
|
68
|
-
// NOTE: As of WCC 0.10.3+,
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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".
|
|
73
|
+
//
|
|
74
|
+
// This plugin is needed for:
|
|
75
|
+
// 1. v-model:propName (Vue can't unwrap CustomEvent.detail natively)
|
|
76
|
+
// 2. v-model modifiers (.trim, .number)
|
|
73
77
|
// 3. Scoped slot syntax transformation ({{prop}} → {%prop%})
|
|
74
78
|
|
|
75
79
|
// Transform v-model:propName="expr" on custom elements (tags with hyphens)
|
package/lib/codegen.js
CHANGED
|
@@ -1799,11 +1799,14 @@ export function generateComponent(parseResult, options = {}) {
|
|
|
1799
1799
|
// _modelSet methods (one per defineModel prop — emits events on internal write)
|
|
1800
1800
|
// Emits:
|
|
1801
1801
|
// 1. wcc:model — generic event for vanilla JS and WCC-to-WCC binding
|
|
1802
|
-
// 2. propName-changed — kebab-case for direct addEventListener
|
|
1802
|
+
// 2. propName-changed — kebab-case for direct addEventListener and Vue plugin
|
|
1803
1803
|
// 3. propNameChanged — camelCase for Angular WccEvents / direct binding
|
|
1804
1804
|
// 4. propnamechanged — lowercase for React 19 (onPropnameChanged → 'propnamechanged')
|
|
1805
1805
|
// 5. propNameChange — for Angular [(prop)] banana-box syntax
|
|
1806
|
-
//
|
|
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.
|
|
1807
1810
|
for (const md of modelDefs) {
|
|
1808
1811
|
const kebabName = camelToKebab(md.name);
|
|
1809
1812
|
const camelChanged = `${md.name}Changed`;
|
|
@@ -1815,16 +1818,14 @@ export function generateComponent(parseResult, options = {}) {
|
|
|
1815
1818
|
lines.push(` bubbles: true,`);
|
|
1816
1819
|
lines.push(` composed: true`);
|
|
1817
1820
|
lines.push(` }));`);
|
|
1818
|
-
// Kebab-case: prop-name-changed
|
|
1821
|
+
// Kebab-case: prop-name-changed (Vue plugin, addEventListener)
|
|
1819
1822
|
lines.push(` this.dispatchEvent(new CustomEvent('${kebabName}-changed', { detail: newVal, bubbles: true }));`);
|
|
1820
|
-
// camelCase: propNameChanged
|
|
1823
|
+
// camelCase: propNameChanged (Angular, addEventListener)
|
|
1821
1824
|
lines.push(` this.dispatchEvent(new CustomEvent('${camelChanged}', { detail: newVal, bubbles: true }));`);
|
|
1822
1825
|
// lowercase: propnamechanged (React 19)
|
|
1823
1826
|
lines.push(` this.dispatchEvent(new CustomEvent('${camelChanged.toLowerCase()}', { detail: newVal, bubbles: true }));`);
|
|
1824
1827
|
// Angular banana-box: propNameChange
|
|
1825
1828
|
lines.push(` this.dispatchEvent(new CustomEvent('${md.name}Change', { detail: newVal, bubbles: true }));`);
|
|
1826
|
-
// Vue v-model: update:propName
|
|
1827
|
-
lines.push(` this.dispatchEvent(new CustomEvent('update:${md.name}', { detail: newVal, bubbles: true }));`);
|
|
1828
1829
|
lines.push(' }');
|
|
1829
1830
|
lines.push('');
|
|
1830
1831
|
}
|
package/package.json
CHANGED