Skip to content

Commit 6a21eb1

Browse files
authored
Support Class Functions as React Props (#221)
* Support class function to react props * test + lint * Support class methods to be passed as prop using 'method' propType and support updates to the class function to rerender the React component with the new method assigned
1 parent 5f503cf commit 6a21eb1

File tree

5 files changed

+178
-5
lines changed

5 files changed

+178
-5
lines changed

docs/api.md

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
- `options.shadow` - ("open", "closed", or undefined) Use the specified shadow DOM mode rather than light DOM.
1010
- `options.events` - Array of camelCasedProps to dispatch as custom events or a Record of event names to their associated [Event constructor options](https://developer.mozilla.org/en-US/docs/Web/API/Event/Event#options).
1111
- When dispatching events from named properties, "on" is stripped from the beginning of the property name if present, and the result is lowercased: the property `onMyCustomEvent` dispatches as "mycustomevent".
12-
- `options.props` - Array of camelCasedProps to watch as String values or { [camelCasedProps]: "string" | "number" | "boolean" | "function" | "json" }
12+
- `options.props` - Array of camelCasedProps to watch as String values or { [camelCasedProps]: "string" | "number" | "boolean" | "function" | "method" | "json" }
1313

1414
- When specifying Array or Object as the type, the string passed into the attribute must pass `JSON.parse()` requirements.
1515
- When specifying Boolean as the type, "true", "1", "yes", "TRUE", and "t" are mapped to `true`. All strings NOT begining with t, T, 1, y, or Y will be `false`.
@@ -109,11 +109,11 @@ console.log(document.body.firstElementChild.innerHTML) // "<h1>Hello, Jane</h1>"
109109
If `options.props` is an object, the keys are the camelCased React props and the values are any one of the following built in javascript types.
110110
This is the recommended way of passing props to r2wc.
111111

112-
`"string" | "number" | "boolean" | "function" | "json"`
112+
`"string" | "number" | "boolean" | "function" | "method" | "json"`
113113

114114
"json" can be an array or object. The string passed into the attribute must pass `JSON.parse()` requirements.
115115

116-
### "string" | "number" | "boolean" | "function" | "json" props
116+
### "string" | "number" | "boolean" | "function" | "method" | "json" props
117117

118118
```js
119119
function AttrPropTypeCasting(props) {
@@ -203,6 +203,49 @@ setTimeout(
203203
// ^ calls globalFn, logs: true, "Jane"
204204
```
205205

206+
207+
### Method props
208+
209+
When `method` is specified as the type, the prop will be bound to a method that can be defined directly on the custom element instance. Unlike `function` props that reference global functions, `method` props allow you to define class methods directly on the web component element, providing better encapsulation and avoiding global namespace pollution.
210+
211+
This is particularly useful when you want to pass functions from parent components or when you need to define behavior specific to each web component instance.
212+
213+
```js
214+
function ClassGreeting({ name, sayHello }) {
215+
return (
216+
<div>
217+
<h1>Hello, {name}</h1>
218+
<button onClick={sayHello}>Click me</button>
219+
</div>
220+
)
221+
}
222+
223+
const WebClassGreeting = reactToWebComponent(ClassGreeting, {
224+
props: {
225+
name: "string",
226+
sayHello: "method",
227+
},
228+
})
229+
230+
customElements.define("class-greeting", WebClassGreeting)
231+
232+
233+
document.body.innerHTML = '<class-greeting name="Christopher"></class-greeting>'
234+
235+
const element = document.querySelector("class-greeting")
236+
237+
const myMethod = function(this: HTMLElement) {
238+
const nameElement = this.querySelector("h1") as HTMLElement;
239+
nameElement.textContent = "Hello, again rerendered";
240+
}
241+
242+
element.sayHello = myMethod.bind(element)
243+
244+
setTimeout(() => {
245+
document.querySelector("class-greeting button").click()
246+
}, 0)
247+
```
248+
206249
### Event dispatching
207250

208251
As an alternative to using function props, the `events` object insructs r2wc to dispatch a corresponding DOM event that can be listened to on the custom element itself, on ancestor elements using `bubbles`, and outside of any containing shadow DOM using `composed`.

packages/core/src/core.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import transforms, { R2WCType } from "./transforms"
2-
import { toDashedCase } from "./utils"
2+
import { toDashedCase, toCamelCase } from "./utils"
33

44
type PropName<Props> = Exclude<Extract<keyof Props, string>, "container">
55
type PropNames<Props> = Array<PropName<Props>>
@@ -34,7 +34,7 @@ const propsSymbol = Symbol.for("r2wc.props")
3434
* @param {ReactComponent}
3535
* @param {Object} options - Optional parameters
3636
* @param {String?} options.shadow - Shadow DOM mode as either open or closed.
37-
* @param {Object|Array?} options.props - Array of camelCasedProps to watch as Strings or { [camelCasedProp]: "string" | "number" | "boolean" | "function" | "json" }
37+
* @param {Object|Array?} options.props - Array of camelCasedProps to watch as Strings or { [camelCasedProp]: "string" | "number" | "boolean" | "function" | "method" | "json" }
3838
*/
3939
export default function r2wc<Props extends R2WCBaseProps, Context>(
4040
ReactComponent: React.ComponentType<Props>,
@@ -106,6 +106,25 @@ export default function r2wc<Props extends R2WCBaseProps, Context>(
106106
const type = propTypes[prop]
107107
const transform = type ? transforms[type] : null
108108

109+
if (type === "method") {
110+
const methodName = toCamelCase(attribute)
111+
112+
Object.defineProperty(this[propsSymbol].container, methodName, {
113+
enumerable: true,
114+
configurable: true,
115+
get() {
116+
return this[propsSymbol][methodName]
117+
},
118+
set(value) {
119+
this[propsSymbol][methodName] = value
120+
this[renderSymbol]()
121+
},
122+
})
123+
124+
//@ts-ignore
125+
this[propsSymbol][prop] = transform.parse(value, attribute, this)
126+
}
127+
109128
if (transform?.parse && value) {
110129
//@ts-ignore
111130
this[propsSymbol][prop] = transform.parse(value, attribute, this)

packages/core/src/transforms/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import boolean from "./boolean"
22
import function_ from "./function"
33
import json from "./json"
4+
import method_ from "./method"
45
import number from "./number"
56
import string from "./string"
67

@@ -14,6 +15,7 @@ const transforms = {
1415
number,
1516
boolean,
1617
function: function_,
18+
method: method_,
1719
json,
1820
}
1921

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { toCamelCase } from "../utils"
2+
3+
import { Transform } from "./index"
4+
5+
const method_: Transform<(...args: unknown[]) => unknown> = {
6+
stringify: (value) => value.name,
7+
parse: (value, attribute, element) => {
8+
const fn = (() => {
9+
const functionName = toCamelCase(attribute)
10+
11+
//@ts-expect-error
12+
if (typeof element !== "undefined" && functionName in element.container) {
13+
// @ts-expect-error
14+
return element.container[functionName]
15+
}
16+
})()
17+
18+
return typeof fn === "function" ? fn.bind(element) : undefined
19+
},
20+
}
21+
22+
export default method_

packages/react-to-web-component/src/react-to-web-component.test.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,4 +363,91 @@ describe("react-to-web-component 1", () => {
363363
button.click()
364364
})
365365
})
366+
367+
it("Supports class function to react props using method transform", async () => {
368+
const ClassGreeting: React.FC<{ name: string; sayHello: () => void }> = ({
369+
name,
370+
sayHello,
371+
}) => (
372+
<div>
373+
<h1>Hello, {name}</h1>
374+
<button onClick={sayHello}>Click me</button>
375+
</div>
376+
)
377+
378+
const WebClassGreeting = r2wc(ClassGreeting, {
379+
props: {
380+
name: "string",
381+
sayHello: "method",
382+
},
383+
})
384+
385+
customElements.define("class-greeting", WebClassGreeting)
386+
387+
document.body.innerHTML = `<class-greeting name='Christopher'></class-greeting>`
388+
389+
const el = document.querySelector<HTMLElement & { sayHello?: () => void }>(
390+
"class-greeting",
391+
)
392+
393+
if (!el) {
394+
throw new Error("Element not found")
395+
}
396+
397+
const sayHello = function (this: HTMLElement) {
398+
const nameElement = this.querySelector("h1")
399+
if (nameElement) {
400+
nameElement.textContent = "Hello, again"
401+
}
402+
}
403+
404+
el.sayHello = sayHello.bind(el)
405+
406+
await new Promise((resolve, reject) => {
407+
const failIfNotClicked = setTimeout(() => {
408+
reject()
409+
}, 1000)
410+
411+
setTimeout(() => {
412+
document
413+
.querySelector<HTMLButtonElement>("class-greeting button")
414+
?.click()
415+
416+
setTimeout(() => {
417+
const element = document.querySelector("h1")
418+
expect(element?.textContent).toEqual("Hello, again")
419+
clearTimeout(failIfNotClicked)
420+
resolve(true)
421+
}, 0)
422+
}, 0)
423+
})
424+
425+
const sayHelloRerendered = function (this: HTMLElement) {
426+
const nameElement = this.querySelector("h1")
427+
if (nameElement) {
428+
nameElement.textContent = "Hello, again rerendered"
429+
}
430+
}
431+
432+
el.sayHello = sayHelloRerendered.bind(el)
433+
434+
await new Promise((resolve, reject) => {
435+
const failIfNotClicked = setTimeout(() => {
436+
reject()
437+
}, 1000)
438+
439+
setTimeout(() => {
440+
document
441+
.querySelector<HTMLButtonElement>("class-greeting button")
442+
?.click()
443+
444+
setTimeout(() => {
445+
const element = document.querySelector<HTMLHeadingElement>("h1")
446+
expect(element?.textContent).toEqual("Hello, again rerendered")
447+
clearTimeout(failIfNotClicked)
448+
resolve(true)
449+
}, 0)
450+
}, 0)
451+
})
452+
})
366453
})

0 commit comments

Comments
 (0)