@sprlab/wccompiler 0.9.9 → 0.10.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.
Files changed (2) hide show
  1. package/adapters/react.js +155 -7
  2. package/package.json +1 -1
package/adapters/react.js CHANGED
@@ -8,19 +8,21 @@
8
8
  * The integrations/react file is for vite.config.js only (contains Babel).
9
9
  *
10
10
  * Usage:
11
- * import { useWccEvent, useWccModel } from '@sprlab/wccompiler/adapters/react'
11
+ * import { useWccEvent, useWccModel, createWccWrapper } from '@sprlab/wccompiler/adapters/react'
12
12
  *
13
- * // Listen to custom events
13
+ * // Option A: Low-level hooks (full control)
14
14
  * const ref = useWccEvent('change', (e) => console.log(e.detail))
15
15
  * <wcc-counter ref={ref}></wcc-counter>
16
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>
17
+ * // Option B: Wrapper components (idiomatic React DX)
18
+ * const WccCounter = createWccWrapper('wcc-counter', {
19
+ * events: ['change'],
20
+ * models: ['count']
21
+ * })
22
+ * <WccCounter onChange={handler} count={count} onCountChanged={setCount} />
21
23
  */
22
24
 
23
- import { useRef, useEffect } from 'react'
25
+ import React, { useRef, useEffect } from 'react'
24
26
 
25
27
  /**
26
28
  * Hook that attaches a CustomEvent listener to a DOM element via ref.
@@ -101,3 +103,149 @@ export function useWccModel(propName, value, setValue, existingRef) {
101
103
 
102
104
  return elementRef
103
105
  }
106
+
107
+
108
+ /**
109
+ * Creates a React wrapper component for a WCC custom element.
110
+ *
111
+ * The wrapper provides idiomatic React DX:
112
+ * - Event props: `onChange`, `onCountChanged` → automatically wired via addEventListener
113
+ * - Model props: two-way binding via attribute + event listener
114
+ * - Regular props: passed as attributes on the custom element
115
+ * - Children: passed through as-is (use `<div slot="name">` for named slots)
116
+ * - Ref forwarding: supports React refs via forwardRef
117
+ *
118
+ * @param {string} tagName - The custom element tag name (e.g., 'wcc-card')
119
+ * @param {Object} [config] - Configuration for the wrapper
120
+ * @param {string[]} [config.events] - Custom event names to expose as onEventName props
121
+ * Event names are converted: 'count-changed' → onCountChanged prop
122
+ * @param {string[]} [config.models] - Model prop names for two-way binding
123
+ * Each model 'name' creates: `name` prop (sets attribute) + `onNameChanged` event
124
+ * @returns {import('react').ForwardRefExoticComponent} A React component
125
+ *
126
+ * @example
127
+ * const WccCounter = createWccWrapper('wcc-counter', {
128
+ * events: ['change'],
129
+ * models: ['count']
130
+ * })
131
+ *
132
+ * function App() {
133
+ * const [count, setCount] = useState(0)
134
+ * return (
135
+ * <WccCounter
136
+ * count={count}
137
+ * onCountChanged={(e) => setCount(e.detail)}
138
+ * onChange={(e) => console.log('changed', e.detail)}
139
+ * label="Clicks"
140
+ * >
141
+ * <div slot="footer">Footer content</div>
142
+ * </WccCounter>
143
+ * )
144
+ * }
145
+ */
146
+ export function createWccWrapper(tagName, config = {}) {
147
+ const { events = [], models = [] } = config
148
+
149
+ // Build a set of event prop names for quick lookup
150
+ // 'count-changed' → 'onCountChanged'
151
+ // 'change' → 'onChange'
152
+ const eventPropMap = new Map()
153
+ for (const eventName of events) {
154
+ const propName = 'on' + eventName.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('')
155
+ eventPropMap.set(propName, eventName)
156
+ }
157
+
158
+ // Model events: 'count' → 'count-changed' → 'onCountChanged'
159
+ const modelEventMap = new Map()
160
+ for (const modelName of models) {
161
+ const eventName = `${modelName}-changed`
162
+ const propName = 'on' + eventName.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('')
163
+ eventPropMap.set(propName, eventName)
164
+ modelEventMap.set(modelName, eventName)
165
+ }
166
+
167
+ // Reserved prop names that should not be passed as attributes
168
+ const SKIP_PROPS = new Set(['children', 'key', 'ref', 'style', 'className', 'dangerouslySetInnerHTML'])
169
+
170
+ const WccWrapper = React.forwardRef(function WccWrapper(props, externalRef) {
171
+ const internalRef = useRef(null)
172
+ const ref = externalRef || internalRef
173
+
174
+ // Store event handlers in a ref to avoid re-subscribing on every render
175
+ const handlersRef = useRef({})
176
+
177
+ // Collect event handlers and regular props
178
+ const regularProps = {}
179
+ const eventHandlers = {}
180
+
181
+ for (const [key, value] of Object.entries(props)) {
182
+ if (SKIP_PROPS.has(key)) continue
183
+
184
+ if (eventPropMap.has(key)) {
185
+ eventHandlers[eventPropMap.get(key)] = value
186
+ } else if (key.startsWith('on') && key.length > 2 && key[2] >= 'A' && key[2] <= 'Z') {
187
+ // Generic React event handler pattern: onClick, onFocus, etc.
188
+ // Convert onSomething → 'something' (lowercase first char)
189
+ const nativeEvent = key[2].toLowerCase() + key.slice(3)
190
+ eventHandlers[nativeEvent] = value
191
+ } else {
192
+ regularProps[key] = value
193
+ }
194
+ }
195
+
196
+ // Update handlers ref
197
+ handlersRef.current = eventHandlers
198
+
199
+ // Subscribe to custom events
200
+ useEffect(() => {
201
+ const el = typeof ref === 'function' ? null : ref?.current
202
+ if (!el) return
203
+
204
+ const listeners = []
205
+ const allEvents = new Set([...eventPropMap.values(), ...Object.keys(eventHandlers)])
206
+
207
+ for (const eventName of allEvents) {
208
+ const listener = (e) => {
209
+ const handler = handlersRef.current[eventName]
210
+ if (handler) handler(e)
211
+ }
212
+ el.addEventListener(eventName, listener)
213
+ listeners.push([eventName, listener])
214
+ }
215
+
216
+ return () => {
217
+ for (const [name, listener] of listeners) {
218
+ el.removeEventListener(name, listener)
219
+ }
220
+ }
221
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
222
+
223
+ // Sync regular props as attributes
224
+ useEffect(() => {
225
+ const el = typeof ref === 'function' ? null : ref?.current
226
+ if (!el) return
227
+
228
+ for (const [key, value] of Object.entries(regularProps)) {
229
+ if (value == null || value === false) {
230
+ el.removeAttribute(key)
231
+ } else if (value === true) {
232
+ el.setAttribute(key, '')
233
+ } else {
234
+ el.setAttribute(key, String(value))
235
+ }
236
+ }
237
+ })
238
+
239
+ // Build the element props for React's createElement
240
+ const elementProps = { ref }
241
+ if (props.style) elementProps.style = props.style
242
+ if (props.className) elementProps.className = props.className
243
+
244
+ return React.createElement(tagName, elementProps, props.children)
245
+ })
246
+
247
+ WccWrapper.displayName = tagName.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('')
248
+
249
+ return WccWrapper
250
+ }
251
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.9.9",
3
+ "version": "0.10.0",
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": {