@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 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. Optional integration helpers reduce configuration friction:
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
- ### Vue 3 (Vite)
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
- export default defineConfig({
650
- plugins: [wccVuePlugin()]
651
- })
652
-
653
- // Custom prefix:
654
- // plugins: [wccVuePlugin({ prefix: 'my-' })]
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
- ### React
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
- React 19+ supports custom elements natively. For React 18, use the event hook to bridge CustomEvents:
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
- import { useRef } from 'react'
663
- import { useWccEvent } from '@sprlab/wccompiler/integrations/react'
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
- // Form 2: Let the hook create the ref
673
- function App2() {
674
- const ref = useWccEvent('change', (e) => console.log(e.detail))
675
- return <wcc-counter ref={ref}></wcc-counter>
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
- ### Angular
769
+ Wrappers read component metadata (`static __meta`) automatically — no manual event/model config needed.
770
+
771
+ ### Angular Directive (scoped slots only)
680
772
 
681
- Add `CUSTOM_ELEMENTS_SCHEMA` to your component or module this is Angular's built-in way to allow custom elements:
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 { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
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: `<wcc-counter></wcc-counter>`
690
- })
691
- export class AppComponent {}
692
-
693
- // Or NgModule approach
694
- @NgModule({
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
- * @returns {import('react').ForwardRefExoticComponent} A React component
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
- * <div slot="footer">Footer content</div>
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} onCountChanged={setCount} />
330
- * <WccCard><div slot="header">Title</div></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
@@ -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+, v-model:propName works WITHOUT this plugin
69
- // because the compiled component emits 'update:propName' natively (Vue 3.4+ CE support).
70
- // This transform is still useful for:
71
- // 1. v-model modifiers (.trim, .number)Vue doesn't apply modifiers to CE v-model natively
72
- // 2. Older Vue versions (< 3.4) that don't support v-model on CE via update:propName
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
- // 6. update:propName — for Vue v-model:propName on custom elements (Vue 3.4+)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
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": {