diff --git a/docs/api.md b/docs/api.md index 3fd50de..ae2fcdc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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`. @@ -109,11 +109,11 @@ console.log(document.body.firstElementChild.innerHTML) // "

Hello, Jane

" 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) { @@ -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 ( +
+

Hello, {name}

+ +
+ ) +} + +const WebClassGreeting = reactToWebComponent(ClassGreeting, { + props: { + name: "string", + sayHello: "method", + }, +}) + +customElements.define("class-greeting", WebClassGreeting) + + +document.body.innerHTML = '' + +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`. diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 8f144af..36938ce 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -1,5 +1,5 @@ import transforms, { R2WCType } from "./transforms" -import { toDashedCase } from "./utils" +import { toDashedCase, toCamelCase } from "./utils" type PropName = Exclude, "container"> type PropNames = Array> @@ -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( ReactComponent: React.ComponentType, @@ -106,6 +106,25 @@ export default function r2wc( 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) diff --git a/packages/core/src/transforms/index.ts b/packages/core/src/transforms/index.ts index 4804692..3bb4e9c 100644 --- a/packages/core/src/transforms/index.ts +++ b/packages/core/src/transforms/index.ts @@ -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" @@ -14,6 +15,7 @@ const transforms = { number, boolean, function: function_, + method: method_, json, } diff --git a/packages/core/src/transforms/method.ts b/packages/core/src/transforms/method.ts new file mode 100644 index 0000000..326209a --- /dev/null +++ b/packages/core/src/transforms/method.ts @@ -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_ diff --git a/packages/react-to-web-component/src/react-to-web-component.test.tsx b/packages/react-to-web-component/src/react-to-web-component.test.tsx index 4d89e7e..f8e5248 100644 --- a/packages/react-to-web-component/src/react-to-web-component.test.tsx +++ b/packages/react-to-web-component/src/react-to-web-component.test.tsx @@ -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, + }) => ( +
+

Hello, {name}

+ +
+ ) + + const WebClassGreeting = r2wc(ClassGreeting, { + props: { + name: "string", + sayHello: "method", + }, + }) + + customElements.define("class-greeting", WebClassGreeting) + + document.body.innerHTML = `` + + const el = document.querySelector 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("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("class-greeting button") + ?.click() + + setTimeout(() => { + const element = document.querySelector("h1") + expect(element?.textContent).toEqual("Hello, again rerendered") + clearTimeout(failIfNotClicked) + resolve(true) + }, 0) + }, 0) + }) + }) })