Skip to content

Support Class Functions as React Props #221

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
- `options.shadow` - ("open", "closed", or undefined) Use the specified shadow DOM mode rather than light DOM.
- `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).
- 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".
- `options.props` - Array of camelCasedProps to watch as String values or { [camelCasedProps]: "string" | "number" | "boolean" | "function" | "json" }
- `options.props` - Array of camelCasedProps to watch as String values or { [camelCasedProps]: "string" | "number" | "boolean" | "function" | "method" | "json" }

- When specifying Array or Object as the type, the string passed into the attribute must pass `JSON.parse()` requirements.
- 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`.
Expand Down Expand Up @@ -109,11 +109,11 @@ console.log(document.body.firstElementChild.innerHTML) // "<h1>Hello, Jane</h1>"
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.
This is the recommended way of passing props to r2wc.

`"string" | "number" | "boolean" | "function" | "json"`
`"string" | "number" | "boolean" | "function" | "method" | "json"`

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

### "string" | "number" | "boolean" | "function" | "json" props
### "string" | "number" | "boolean" | "function" | "method" | "json" props

```js
function AttrPropTypeCasting(props) {
Expand Down Expand Up @@ -203,6 +203,49 @@ setTimeout(
// ^ calls globalFn, logs: true, "Jane"
```


### Method props

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.

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.

```js
function ClassGreeting({ name, sayHello }) {
return (
<div>
<h1>Hello, {name}</h1>
<button onClick={sayHello}>Click me</button>
</div>
)
}

const WebClassGreeting = reactToWebComponent(ClassGreeting, {
props: {
name: "string",
sayHello: "method",
},
})

customElements.define("class-greeting", WebClassGreeting)


document.body.innerHTML = '<class-greeting name="Christopher"></class-greeting>'

const element = document.querySelector("class-greeting")

const myMethod = function(this: HTMLElement) {
const nameElement = this.querySelector("h1") as HTMLElement;
nameElement.textContent = "Hello, again rerendered";
}

element.sayHello = myMethod.bind(element)

setTimeout(() => {
document.querySelector("class-greeting button").click()
}, 0)
```

### Event dispatching

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`.
Expand Down
23 changes: 21 additions & 2 deletions packages/core/src/core.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import transforms, { R2WCType } from "./transforms"
import { toDashedCase } from "./utils"
import { toDashedCase, toCamelCase } from "./utils"

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

if (type === "method") {
const methodName = toCamelCase(attribute)

Object.defineProperty(this[propsSymbol].container, methodName, {
enumerable: true,
configurable: true,
get() {
return this[propsSymbol][methodName]
},
set(value) {
this[propsSymbol][methodName] = value
this[renderSymbol]()
},
})

//@ts-ignore
this[propsSymbol][prop] = transform.parse(value, attribute, this)
}

if (transform?.parse && value) {
//@ts-ignore
this[propsSymbol][prop] = transform.parse(value, attribute, this)
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/transforms/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import boolean from "./boolean"
import function_ from "./function"
import json from "./json"
import method_ from "./method"
import number from "./number"
import string from "./string"

Expand All @@ -14,6 +15,7 @@ const transforms = {
number,
boolean,
function: function_,
method: method_,
json,
}

Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/transforms/method.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { toCamelCase } from "../utils"

import { Transform } from "./index"

const method_: Transform<(...args: unknown[]) => unknown> = {
stringify: (value) => value.name,
parse: (value, attribute, element) => {
const fn = (() => {
const functionName = toCamelCase(attribute)

//@ts-expect-error
if (typeof element !== "undefined" && functionName in element.container) {
// @ts-expect-error
return element.container[functionName]
}
})()

return typeof fn === "function" ? fn.bind(element) : undefined
},
}

export default method_
Original file line number Diff line number Diff line change
Expand Up @@ -363,4 +363,91 @@ describe("react-to-web-component 1", () => {
button.click()
})
})

it("Supports class function to react props using method transform", async () => {
const ClassGreeting: React.FC<{ name: string; sayHello: () => void }> = ({
name,
sayHello,
}) => (
<div>
<h1>Hello, {name}</h1>
<button onClick={sayHello}>Click me</button>
</div>
)

const WebClassGreeting = r2wc(ClassGreeting, {
props: {
name: "string",
sayHello: "method",
},
})

customElements.define("class-greeting", WebClassGreeting)

document.body.innerHTML = `<class-greeting name='Christopher'></class-greeting>`

const el = document.querySelector<HTMLElement & { sayHello?: () => void }>(
"class-greeting",
)

if (!el) {
throw new Error("Element not found")
}

const sayHello = function (this: HTMLElement) {
const nameElement = this.querySelector("h1")
if (nameElement) {
nameElement.textContent = "Hello, again"
}
}

el.sayHello = sayHello.bind(el)

await new Promise((resolve, reject) => {
const failIfNotClicked = setTimeout(() => {
reject()
}, 1000)

setTimeout(() => {
document
.querySelector<HTMLButtonElement>("class-greeting button")
?.click()

setTimeout(() => {
const element = document.querySelector("h1")
expect(element?.textContent).toEqual("Hello, again")
clearTimeout(failIfNotClicked)
resolve(true)
}, 0)
}, 0)
})

const sayHelloRerendered = function (this: HTMLElement) {
const nameElement = this.querySelector("h1")
if (nameElement) {
nameElement.textContent = "Hello, again rerendered"
}
}

el.sayHello = sayHelloRerendered.bind(el)

await new Promise((resolve, reject) => {
const failIfNotClicked = setTimeout(() => {
reject()
}, 1000)

setTimeout(() => {
document
.querySelector<HTMLButtonElement>("class-greeting button")
?.click()

setTimeout(() => {
const element = document.querySelector<HTMLHeadingElement>("h1")
expect(element?.textContent).toEqual("Hello, again rerendered")
clearTimeout(failIfNotClicked)
resolve(true)
}, 0)
}, 0)
})
})
})