From 1fc62bffccc08d58eed9f81a8a5d6a4dcd782af7 Mon Sep 17 00:00:00 2001 From: janebitovi Date: Thu, 4 Aug 2022 12:57:59 -0400 Subject: [PATCH 1/3] for: (R2WC-19) - allow 'on-*' and 'handle-*' attrs to reference global functions and pass the fn to the underlying react component instead --- README.md | 17 ++--- docs/api.md | 71 ++++++++++++++++---- src/react-to-webcomponent.test.jsx | 100 ++++++++++++++++++++++++++++- src/react-to-webcomponent.ts | 7 ++ 4 files changed, 169 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index ca4b860..69b98ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # react-to-webcomponent -`react-to-webcomponent` converts [React](https://reactjs.org/) components to [custom elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements)! It lets you share React components as native elements that __don't__ require mounted being through React. The custom element acts as a wrapper for the underlying React component. Use these custom elements with any project that uses HTML even in any framework (vue, svelte, angular, ember, canjs) the same way you would use standard HTML elements. +`react-to-webcomponent` converts [React](https://reactjs.org/) components to [custom elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements)! It lets you share React components as native elements that **don't** require mounted being through React. The custom element acts as a wrapper for the underlying React component. Use these custom elements with any project that uses HTML even in any framework (vue, svelte, angular, ember, canjs) the same way you would use standard HTML elements. `react-to-webcomponent`: @@ -26,10 +26,8 @@ import * as ReactDOM from "react-dom/client" // When using React 16 and 17 import ReactDom with the commented statement below instead: // import ReactDom from "react-dom" -const Greeting = ({name}) => { - return ( -

Hello, {name}

- ) +const Greeting = ({ name }) => { + return

Hello, {name}

} ``` @@ -55,7 +53,6 @@ Now we can use `` like any other HTML element! Note that by using React 18, `reactToWebComponent` will use the new root API. If your application needs the legacy API, please use React 17 - In the above case, the web-greeting custom element is not making use of the `name` property from our `Greeting` component. ## Working with Attributes @@ -71,13 +68,11 @@ import PropTypes from "prop-types" import * as ReactDOM from "react-dom/client" const Greeting = ({ name }) => { - return ( -

Hello, {name}

- ) + return

Hello, {name}

} Greeting.propTypes = { - name: PropTypes.string.isRequired + name: PropTypes.string.isRequired, } ``` @@ -92,7 +87,7 @@ as follows: ``` -For projects needing more advanced usage of the web components, see our [prgramatic usage and declarative demos](docs/programatic-usage.md). +For projects needing more advanced usage of the web components, see our [programatic usage and declarative demos](docs/programatic-usage.md). We also have a [complete example using a third party library](docs/complete-example.md). diff --git a/docs/api.md b/docs/api.md index 1387cb9..6f3497e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,14 +8,18 @@ component works with. - `ReactDOM` - A version of ReactDOM (or preact-compat) that the component works with. - `options` - An optional set of parameters. + - `options.shadow` - Use shadow DOM rather than light DOM. - `options.dashStyleAttributes` - convert dashed-attirbutes on the web component into camelCase props for the React component A new class inheriting from `HTMLElement` is -returned. This class can be directly passed to `customElements.define` as follows: + returned. This class can be directly passed to `customElements.define` as follows: ```js -customElements.define("web-greeting", reactToWebComponent(Greeting, React, ReactDOM)) +customElements.define( + "web-greeting", + reactToWebComponent(Greeting, React, ReactDOM), +) ``` Or the class can be defined and used later: @@ -32,12 +36,11 @@ document.body.appendChild(myGreeting) Or the class can be extended: ```js -class WebGreeting extends reactToWebComponent(Greeting, React, ReactDOM) -{ - disconnectedCallback(){ - super.disconnectedCallback() - // special stuff - } +class WebGreeting extends reactToWebComponent(Greeting, React, ReactDOM) { + disconnectedCallback() { + super.disconnectedCallback() + // special stuff + } } customElements.define("web-greeting", WebGreeting) ``` @@ -45,7 +48,9 @@ customElements.define("web-greeting", WebGreeting) Components can also be implemented using [shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM) with either `open` or `closed` mode. ```js -const WebGreeting = reactToWebComponent(Greeting, React, ReactDOM, { shadow: 'open' }) +const WebGreeting = reactToWebComponent(Greeting, React, ReactDOM, { + shadow: "open", +}) customElements.define("web-greeting", WebGreeting) @@ -59,20 +64,58 @@ Using dashStyleAttributes to convert dashed-attributes into camelCase React prop ```js class Greeting extends React.Component { - render () { - return

Hello, { this.props.camelCaseName }

+ render() { + return

Hello, {this.props.camelCaseName}

} } Greeting.propTypes = { - camelCaseName: PropTypes.string.isRequired + camelCaseName: PropTypes.string.isRequired, } customElements.define( "my-dashed-style-greeting", - reactToWebComponent(Greeting, React, ReactDOM, { dashStyleAttributes: true }) + reactToWebComponent(Greeting, React, ReactDOM, { dashStyleAttributes: true }), ) -document.body.innerHTML = "" +document.body.innerHTML = + '' console.log(document.body.firstElementChild.innerHTML) // "

Hello, Christopher

" ``` + +Further, attributes on the web component beginning with `on-` or `handle-` will be converted into function references when passed into the underlying React component if the string value is a valid reference to a function on `window` (or on `global`). + +```js +function ThemeSelect({ handleClick }) { + return ( +
+ + + +
+ ) +} + +ThemeSelect.propTypes = { + handleClick: PropTypes.func.isRequired, +} + +const WebThemeSelect = reactToWebComponent(ThemeSelect, React, ReactDOM, { + dashStyleAttributes: true, +}) + +customElements.define("theme-select", WebThemeSelect) + +window.globalFn = (selected) => { + console.log(selected) +} + +document.body.innerHTML = + "" + +setTimeout( + () => document.querySelector("theme-select button:last-child").click(), + 0, +) +// ^ calls globalFn, logs "Jane" +``` diff --git a/src/react-to-webcomponent.test.jsx b/src/react-to-webcomponent.test.jsx index 2f52968..8705e6c 100644 --- a/src/react-to-webcomponent.test.jsx +++ b/src/react-to-webcomponent.test.jsx @@ -93,7 +93,7 @@ test("works within can-stache and can-stache-bindings (propTypes are writable)", expect(myWelcome.childNodes.length).toEqual(1) expect(myWelcome.childNodes[0].innerHTML).toEqual("Hello, Bohdi") r() - }, 100) + }, 250) }) }) @@ -242,3 +242,101 @@ test("mounts and unmounts underlying react component", async () => { }, 0) }) }) + +test('Dashed attributes styled set to "true" will also convert the string value of attributes starting with "handle-" into global fn calls', async () => { + expect.assertions(1) + + function ThemeSelect({ handleClick }) { + return ( +
+ + + +
+ ) + } + + ThemeSelect.propTypes = { + handleClick: PropTypes.func.isRequired, + } + + const WebThemeSelect = reactToWebComponent(ThemeSelect, React, ReactDOM, { + dashStyleAttributes: true, + }) + + customElements.define("theme-select", WebThemeSelect) + + const body = document.body + + await new Promise((r) => { + const failUnlessCleared = setTimeout(() => { + delete global.globalFn + expect("globalFn was not called to clear the failure timeout").toEqual( + "not to fail because globalFn should have been called to clear the failure timeout", + ) + r() + }, 1000) + + global.globalFn = (selected) => { + delete global.globalFn + clearTimeout(failUnlessCleared) + expect(selected).toEqual("Jane") + r() + } + + body.innerHTML = "" + + setTimeout(() => { + document.querySelector("theme-select button:last-child").click() + }, 0) + }) +}) + +test('Dashed attributes styled set to "true" will also convert the string value of attributes starting with "on-" into global fn calls', async () => { + expect.assertions(1) + + function ThemeSelectToo({ onData }) { + return ( +
+ + + +
+ ) + } + + ThemeSelectToo.propTypes = { + onData: PropTypes.func.isRequired, + } + + const WebThemeSelectToo = reactToWebComponent(ThemeSelectToo, React, ReactDOM, { + dashStyleAttributes: true, + }) + + customElements.define("theme-select-too", WebThemeSelectToo) + + const body = document.body + + await new Promise((r) => { + const failUnlessCleared = setTimeout(() => { + delete global.globalFn + expect("globalFn was not called to clear the failure timeout").toEqual( + "not to fail because globalFn should have been called to clear the failure timeout", + ) + r() + }, 1000) + + global.globalFn = (selected) => { + delete global.globalFn + clearTimeout(failUnlessCleared) + expect(selected).toEqual("Jane") + r() + } + + body.innerHTML = "" + + setTimeout(() => { + document.querySelector("theme-select-too button:last-child").click() + }, 0) + }) +}) diff --git a/src/react-to-webcomponent.ts b/src/react-to-webcomponent.ts index 87dab4c..decb9a7 100644 --- a/src/react-to-webcomponent.ts +++ b/src/react-to-webcomponent.ts @@ -188,6 +188,13 @@ export default function ( const propertyName = options.dashStyleAttributes ? toCamelCaseStyle(name) : name + if (name.match(/^(?:on|handle)-/)) { + if (typeof window !== "undefined") { + newValue = window[newValue] || newValue + } else if (typeof global !== "undefined") { + newValue = global[newValue] || newValue + } + } this[propertyName] = newValue } } From ed2308dd574057ca6e9050719797c44c0c52ccc5 Mon Sep 17 00:00:00 2001 From: janebitovi Date: Fri, 5 Aug 2022 16:18:50 -0400 Subject: [PATCH 2/3] for: (R2WC-17) remove propTypes dep, add attr to prop type casting option, bind Function params to the webcomponent instance --- docs/api.md | 105 ++++++++++++++--- src/react-to-webcomponent.test.jsx | 179 +++++++++++++++++++++-------- src/react-to-webcomponent.ts | 86 ++++++++------ 3 files changed, 279 insertions(+), 91 deletions(-) diff --git a/docs/api.md b/docs/api.md index 6f3497e..fca2742 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,7 +10,11 @@ - `options` - An optional set of parameters. - `options.shadow` - Use shadow DOM rather than light DOM. - - `options.dashStyleAttributes` - convert dashed-attirbutes on the web component into camelCase props for the React component + - `options.props` - Array of camelCasedProps to watch as String values or { [camelCasedProps]: String | Number | Boolean | Function | Object | Array } + + - 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`. + - When specifying Function as the type, the string passed into the attribute must be the name of a function on `window` (or `global`). The `this` context of the function will be the instance of the WebComponent / HTMLElement when called. A new class inheriting from `HTMLElement` is returned. This class can be directly passed to `customElements.define` as follows: @@ -60,7 +64,7 @@ document.body.appendChild(myGreeting) var shadowContent = myGreeting.shadowRoot.children[0] ``` -Using dashStyleAttributes to convert dashed-attributes into camelCase React props +If propTypes are defined on the underlying React component, dashed-attributes on the webcomponent are converted into the corresponding camelCase React props and the string attribute value is passed in. ```js class Greeting extends React.Component { @@ -74,16 +78,91 @@ Greeting.propTypes = { customElements.define( "my-dashed-style-greeting", - reactToWebComponent(Greeting, React, ReactDOM, { dashStyleAttributes: true }), + reactToWebComponent(Greeting, React, ReactDOM, {}), ) document.body.innerHTML = - '' + '' console.log(document.body.firstElementChild.innerHTML) // "

Hello, Christopher

" ``` -Further, attributes on the web component beginning with `on-` or `handle-` will be converted into function references when passed into the underlying React component if the string value is a valid reference to a function on `window` (or on `global`). +If `options.props` is specified, R2WC will use those props instead of the keys from propTypes. If it's an array, all corresponding kebob-cased attr values will be passed as strings to the underlying React component. + +```js +class Greeting extends React.Component { + render() { + return

Hello, {this.props.camelCaseName}

+ } +} + +customElements.define( + "my-dashed-style-greeting", + reactToWebComponent(Greeting, React, ReactDOM, { + props: ["camelCaseName"], + }), +) + +document.body.innerHTML = + '' + +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: + +`String | Number | Boolean | Function | Object | Array` + +```js +class AttrPropTypeCasting extends React.Component { + render() { + console.log(this.props) // Note + return

Oh my, {this.props.stringProp}

+ } +} + +customElements.define( + "attr-prop-type-casting", + reactToWebComponent(AttrPropTypeCasting, React, ReactDOM, { + props: { + stringProp: String, + numProp: Number, + floatProp: Number, + trueProp: Boolean, + falseProp: Boolean, + arrayProp: Array, + objProp: Object, + }, + }), +) + +document.body.innerHTML = ` + +` + +/* + console.log(this.props) in the React render function produces this: + { + stringProp: "iloveyou", + numProp: 360, + floatProp: 0.5, + trueProp: true, + falseProp: false, + arrayProp: [true, 100.25, "👽", { aliens: "welcome" }], + objProp: { very: "object", such: "wow!" }, + } +*/ +``` + +When `Function` is specified as the type, attribute values on the web component will be converted into function references when passed into the underlying React component. The string value of the attribute must be a valid reference to a function on `window` (or on `global`). ```js function ThemeSelect({ handleClick }) { @@ -96,18 +175,18 @@ function ThemeSelect({ handleClick }) { ) } -ThemeSelect.propTypes = { - handleClick: PropTypes.func.isRequired, -} - const WebThemeSelect = reactToWebComponent(ThemeSelect, React, ReactDOM, { - dashStyleAttributes: true, + props: { + handleClick: Function, + }, }) customElements.define("theme-select", WebThemeSelect) -window.globalFn = (selected) => { - console.log(selected) +window.globalFn = function (selected) { + // "this" is the instance of the WebComponent / HTMLElement + const thisIsEl = this === document.querySelector("theme-select") + console.log(thisIsEl, selected) } document.body.innerHTML = @@ -117,5 +196,5 @@ setTimeout( () => document.querySelector("theme-select button:last-child").click(), 0, ) -// ^ calls globalFn, logs "Jane" +// ^ calls globalFn, logs: true, "Jane" ``` diff --git a/src/react-to-webcomponent.test.jsx b/src/react-to-webcomponent.test.jsx index 8705e6c..c26174c 100644 --- a/src/react-to-webcomponent.test.jsx +++ b/src/react-to-webcomponent.test.jsx @@ -166,7 +166,7 @@ test('It works without shadow option set to "true"', async () => { }) }) -test('It works with dashed attributes styled set to "true"', async () => { +test("It converts dashed-attributes to camelCase", async () => { expect.assertions(1) function Greeting({ camelCaseName }) { @@ -177,9 +177,7 @@ test('It works with dashed attributes styled set to "true"', async () => { camelCaseName: PropTypes.string.isRequired, } - const MyGreeting = reactToWebComponent(Greeting, React, ReactDOM, { - dashStyleAttributes: true, - }) + const MyGreeting = reactToWebComponent(Greeting, React, ReactDOM, {}) customElements.define("my-dashed-style-greeting", MyGreeting) @@ -243,77 +241,165 @@ test("mounts and unmounts underlying react component", async () => { }) }) -test('Dashed attributes styled set to "true" will also convert the string value of attributes starting with "handle-" into global fn calls', async () => { +test("options.props can be used as an array of props instead of relying on keys from propTypes", async () => { expect.assertions(1) - function ThemeSelect({ handleClick }) { + function PropTypesNotRequired({ greeting, camelCaseName }) { return ( -
- - - -
+

+ {greeting}, {camelCaseName} +

) } - ThemeSelect.propTypes = { - handleClick: PropTypes.func.isRequired, - } + const WebPropTypesNotRequired = reactToWebComponent( + PropTypesNotRequired, + React, + ReactDOM, + { + props: ["greeting", "camelCaseName"], + }, + ) - const WebThemeSelect = reactToWebComponent(ThemeSelect, React, ReactDOM, { - dashStyleAttributes: true, - }) - - customElements.define("theme-select", WebThemeSelect) + customElements.define("web-proptypes-not-required", WebPropTypesNotRequired) const body = document.body + body.innerHTML = + "" + await new Promise((r) => { - const failUnlessCleared = setTimeout(() => { - delete global.globalFn - expect("globalFn was not called to clear the failure timeout").toEqual( - "not to fail because globalFn should have been called to clear the failure timeout", - ) + setTimeout(() => { + expect(body.firstElementChild.innerHTML).toEqual("

Ayy, lmao

") r() - }, 1000) + }, 0) + }) +}) - global.globalFn = (selected) => { - delete global.globalFn - clearTimeout(failUnlessCleared) - expect(selected).toEqual("Jane") - r() +test("options.props can specify and will convert the String attribute value into Number, Boolean, Array, and/or Object", async () => { + expect.assertions(12) + + function OptionsPropsTypeCasting({ + stringProp, + numProp, + floatProp, + trueProp, + falseProp, + arrayProp, + objProp, + }) { + global.castedValues = { + stringProp, + numProp, + floatProp, + trueProp, + falseProp, + arrayProp, + objProp, } + return

{stringProp}

+ } - body.innerHTML = "" + OptionsPropsTypeCasting.propTypes = { + stringProp: PropTypes.string.isRequired, + numProp: PropTypes.number.isRequired, + floatProp: PropTypes.number.isRequired, + trueProp: PropTypes.bool.isRequired, + falseProp: PropTypes.bool.isRequired, + arrayProp: PropTypes.array.isRequired, + objProp: PropTypes.object.isRequired, + } + + const WebOptionsPropsTypeCasting = reactToWebComponent( + OptionsPropsTypeCasting, + React, + ReactDOM, + { + props: { + stringProp: String, + numProp: Number, + floatProp: Number, + trueProp: Boolean, + falseProp: Boolean, + arrayProp: Array, + objProp: Object, + }, + }, + ) + + customElements.define("attr-type-casting", WebOptionsPropsTypeCasting) + + const body = document.body + + console.error = function (...messages) { + // propTypes will throw if any of the types passed into the underlying react component are wrong or missing + expect("propTypes should not have thrown").toEqual(messages.join("")) + } + body.innerHTML = ` + + ` + + await new Promise((r) => { setTimeout(() => { - document.querySelector("theme-select button:last-child").click() + const { + stringProp, + numProp, + floatProp, + trueProp, + falseProp, + arrayProp, + objProp, + } = global.castedValues + expect(stringProp).toEqual("iloveyou") + expect(numProp).toEqual(360) + expect(floatProp).toEqual(0.5) + expect(trueProp).toEqual(true) + expect(falseProp).toEqual(false) + expect(arrayProp.length).toEqual(4) + expect(arrayProp[0]).toEqual(true) + expect(arrayProp[1]).toEqual(100.25) + expect(arrayProp[2]).toEqual("👽") + expect(arrayProp[3].aliens).toEqual("welcome") + expect(objProp.very).toEqual("object") + expect(objProp.such).toEqual("wow!") + r() }, 0) }) }) -test('Dashed attributes styled set to "true" will also convert the string value of attributes starting with "on-" into global fn calls', async () => { - expect.assertions(1) +test("Props typed as Function convert the string value of attribute into global fn calls bound to the webcomponent instance", async () => { + expect.assertions(2) - function ThemeSelectToo({ onData }) { + function ThemeSelect({ handleClick }) { return (
- - - + + +
) } - ThemeSelectToo.propTypes = { - onData: PropTypes.func.isRequired, + ThemeSelect.propTypes = { + handleClick: PropTypes.func.isRequired, } - const WebThemeSelectToo = reactToWebComponent(ThemeSelectToo, React, ReactDOM, { - dashStyleAttributes: true, + const WebThemeSelect = reactToWebComponent(ThemeSelect, React, ReactDOM, { + props: { + handleClick: Function, + }, }) - customElements.define("theme-select-too", WebThemeSelectToo) + customElements.define("theme-select", WebThemeSelect) const body = document.body @@ -326,17 +412,18 @@ test('Dashed attributes styled set to "true" will also convert the string value r() }, 1000) - global.globalFn = (selected) => { + global.globalFn = function (selected) { delete global.globalFn clearTimeout(failUnlessCleared) expect(selected).toEqual("Jane") + expect(this).toEqual(document.querySelector("theme-select")) r() } - body.innerHTML = "" + body.innerHTML = "" setTimeout(() => { - document.querySelector("theme-select-too button:last-child").click() + document.querySelector("theme-select button:last-child").click() }, 0) }) }) diff --git a/src/react-to-webcomponent.ts b/src/react-to-webcomponent.ts index decb9a7..7d262cd 100644 --- a/src/react-to-webcomponent.ts +++ b/src/react-to-webcomponent.ts @@ -6,12 +6,6 @@ function toDashedStyle(camelCase = "") { return camelCase.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() } -function toCamelCaseStyle(dashedStyle = "") { - return dashedStyle.replace(/-([a-z0-9])/g, function (g) { - return g[1].toUpperCase() - }) -} - const define = { // Creates a getter/setter that re-renders everytime a property is set. expando: function (receiver: object, key: string, value: unknown) { @@ -29,11 +23,6 @@ const define = { }, } -interface R2WCOptions { - shadow?: string | boolean - dashStyleAttributes?: boolean -} - interface React { createElement: ( ReactComponent: object, @@ -50,6 +39,11 @@ interface ReactDOM { ) => unknown } +interface R2WCOptions { + shadow?: string | boolean + props?: Array | Record +} + /** * Converts a React component into a webcomponent by wrapping it in a Proxy object. * @param {ReactComponent} @@ -57,7 +51,7 @@ interface ReactDOM { * @param {ReactDOM} * @param {Object} options - Optional parameters * @param {String?} options.shadow - Shadow DOM mode as either open or closed. - * @param {String?} options.dashStyleAttributes - Use dashed style of attributes to reflect camelCase properties + * @param {Object|Array?} options.props - Array of camelCasedProps to watch as Strings or { [camelCasedProp]: String | Number | Boolean | Function | Object | Array } */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export default function ( @@ -66,6 +60,23 @@ export default function ( ReactDOM: ReactDOM, options: R2WCOptions = {}, ) { + const propTypes = {} // { [camelCasedProp]: String | Number | Boolean | Function | Object | Array } + const propAttrMap = {} // @TODO: add option to specify for asymetric mapping (eg "className" from "class") + const attrPropMap = {} // cached inverse of propAttrMap + if (!options.props) { + options.props = ReactComponent.propTypes + ? Object.keys(ReactComponent.propTypes) + : [] + } + const propKeys = Array.isArray(options.props) + ? options.props.slice() + : Object.keys(options.props) + const optionsPropsIsArray = Array.isArray(options.props) + propKeys.forEach((key) => { + propTypes[key] = optionsPropsIsArray ? String : options.props[key] + propAttrMap[key] = toDashedStyle(key) + attrPropMap[propAttrMap[key]] = key + }) const renderAddedProperties = { isConnected: "isConnected" in HTMLElement.prototype, } @@ -91,7 +102,7 @@ export default function ( // But have that prototype be wrapped in a proxy. const proxyPrototype = new Proxy(targetPrototype, { has: function (target, key) { - return key in ReactComponent.propTypes || key in targetPrototype + return key in propTypes || key in targetPrototype }, // when any undefined property is set, create a getter/setter that re-renders @@ -117,7 +128,7 @@ export default function ( if (own) { return own } - if (key in ReactComponent.propTypes) { + if (key in propTypes) { return { configurable: true, enumerable: true, @@ -173,30 +184,41 @@ export default function ( } // Handle attributes changing - if (ReactComponent.propTypes) { - WebComponent.observedAttributes = options.dashStyleAttributes - ? Object.keys(ReactComponent.propTypes).map(function (key) { - return toDashedStyle(key) - }) - : Object.keys(ReactComponent.propTypes) - targetPrototype.attributeChangedCallback = function ( - name: string, - oldValue, - newValue, - ) { - // @TODO: handle type conversion - const propertyName = options.dashStyleAttributes - ? toCamelCaseStyle(name) - : name - if (name.match(/^(?:on|handle)-/)) { + WebComponent.observedAttributes = Object.keys(attrPropMap) + + targetPrototype.attributeChangedCallback = function ( + name: string, + oldValue, + newValue, + ) { + const propertyName = attrPropMap[name] || name + switch (propTypes[propertyName]) { + case Function: if (typeof window !== "undefined") { newValue = window[newValue] || newValue } else if (typeof global !== "undefined") { newValue = global[newValue] || newValue } - } - this[propertyName] = newValue + if (typeof newValue === "function") { + newValue = newValue.bind(this) // this = instance of the WebComponent / HTMLElement + } + break + case Number: + newValue = parseFloat(newValue) + break + case Boolean: + newValue = /^[ty1-9]/i.test(newValue) + break + case Object: + case Array: + newValue = JSON.parse(newValue) + break + case String: + default: + break } + + this[propertyName] = newValue } return WebComponent From a7bf224cd5406d1cdd50a4397e514c6042173fcc Mon Sep 17 00:00:00 2001 From: janebitovi Date: Mon, 8 Aug 2022 16:30:59 -0400 Subject: [PATCH 3/3] for: (R2WC-20) allow React ref prop types --- docs/api.md | 59 ++++++++++++++++++++-- src/react-to-webcomponent.test.jsx | 79 ++++++++++++++++++++++++++++++ src/react-to-webcomponent.ts | 8 ++- 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/docs/api.md b/docs/api.md index fca2742..bca743f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,7 +10,7 @@ - `options` - An optional set of parameters. - `options.shadow` - Use shadow DOM rather than light DOM. - - `options.props` - Array of camelCasedProps to watch as String values or { [camelCasedProps]: String | Number | Boolean | Function | Object | Array } + - `options.props` - Array of camelCasedProps to watch as String values or { [camelCasedProps]: String | Number | Boolean | Function | Object | Array | "ref" } - 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,9 +109,13 @@ document.body.innerHTML = 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: +## Typed Props -`String | Number | Boolean | Function | Object | Array` +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, or the string "ref": + +`String | Number | Boolean | Function | Object | Array | "ref"` + +### String | Number | Boolean | Object | Array props ```js class AttrPropTypeCasting extends React.Component { @@ -162,6 +166,8 @@ document.body.innerHTML = ` */ ``` +### Function props + When `Function` is specified as the type, attribute values on the web component will be converted into function references when passed into the underlying React component. The string value of the attribute must be a valid reference to a function on `window` (or on `global`). ```js @@ -198,3 +204,50 @@ setTimeout( ) // ^ calls globalFn, logs: true, "Jane" ``` + +### "ref" props + +If the React component is a class type or has ref props, you can specify attributes as `"ref"` type and `React.createRef()` will automatically happen behind the scenes then attach the reference to the webcomponent instance. + +```js +class ComRef extends React.Component { + render() { + return

Ref

+ } +} + +class WebComRef extends reactToWebComponent(ComRef, React, ReactDOM, { + props: { + ref: "ref", + h1Ref: "ref", + }, +}) {} + +customElements.define("ref-example", WebComRef) + +document.body.innerHTML = "" + +setTimeout(() => { + const el = document.querySelector("ref-example") + + console.log(el.ref.current instanceof ComRef) // logs true + + const h1 = el.querySelector("h1") + + console.log(el.h1Ref.current === h1) // logs true +}, 0) +``` + +If your `"ref"` type webcomponent attribute specifies a value, the value will be the name of a global function (like the `Function` prop type above) and be used as a callback reference, recieving the dom element the React component attaches it to as a parameter. + +```js +window.globalRefFn = function (el) { + if (!el) { + // if the component rerenders the referenced element, the callback may run with el = null + return + } + console.log(el === this.querySelector("h1")) // logs true +} + +body.innerHTML = "" +``` diff --git a/src/react-to-webcomponent.test.jsx b/src/react-to-webcomponent.test.jsx index c26174c..57b6ff6 100644 --- a/src/react-to-webcomponent.test.jsx +++ b/src/react-to-webcomponent.test.jsx @@ -427,3 +427,82 @@ test("Props typed as Function convert the string value of attribute into global }, 0) }) }) + +test("Props typed as 'ref' work", async () => { + expect.assertions(8) + + class RCom extends React.Component { + constructor(props) { + super(props) + this.state = { tag: "h1" } + } + render() { + const Tag = this.state.tag + return ( + this.setState({ tag: "h2" })} + > + Ref + + ) + } + } + + class WebCom extends reactToWebComponent(RCom, React, ReactDOM, { + props: { + ref: "ref", + h1Ref: "ref", + }, + }) {} + + customElements.define("ref-test", WebCom) + + const body = document.body + + await new Promise((r) => { + body.innerHTML = "" + + setTimeout(() => { + const el = document.querySelector("ref-test") + expect(el.ref.current instanceof RCom).toEqual(true) + const h1 = document.querySelector("ref-test h1") + expect(el.h1Ref.current).toEqual(h1) + h1.click() + setTimeout(() => { + const h2 = document.querySelector("ref-test h2") + expect(el.h1Ref.current).not.toEqual(h1) + expect(el.h1Ref.current).toEqual(h2) + r() + }, 0) + }, 0) + }) + + await new Promise((r) => { + const failUnlessCleared = setTimeout(() => { + delete global.globalRefFn + expect("globalRefFn was not called to clear the failure timeout").toEqual( + "not to fail because globalRefFn should have been called to clear the failure timeout", + ) + r() + }, 1000) + + global.globalRefFn = function (el) { + if (!el) { + // null before it switches to h2 + return + } + expect(this).toEqual(document.querySelector("ref-test")) + expect(el).toEqual(this.querySelector("h1, h2")) + if (el.tagName.toLowerCase() === "h1") { + el.click() + } else { + delete global.globalRefFn + clearTimeout(failUnlessCleared) + r() + } + } + + body.innerHTML = "" + }) +}) diff --git a/src/react-to-webcomponent.ts b/src/react-to-webcomponent.ts index 7d262cd..7a739be 100644 --- a/src/react-to-webcomponent.ts +++ b/src/react-to-webcomponent.ts @@ -3,7 +3,7 @@ const shouldRenderSymbol = Symbol.for("r2wc.shouldRender") const rootSymbol = Symbol.for("r2wc.root") function toDashedStyle(camelCase = "") { - return camelCase.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() + return camelCase.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase() } const define = { @@ -24,6 +24,7 @@ const define = { } interface React { + createRef: () => Record createElement: ( ReactComponent: object, data: object, @@ -193,7 +194,12 @@ export default function ( ) { const propertyName = attrPropMap[name] || name switch (propTypes[propertyName]) { + case "ref": case Function: + if (!newValue && propTypes[propertyName] === "ref") { + newValue = React.createRef() + break + } if (typeof window !== "undefined") { newValue = window[newValue] || newValue } else if (typeof global !== "undefined") {