From f1a9adb1f62ddb0dd59d340289931e0d6b38452d Mon Sep 17 00:00:00 2001 From: Bavin Edwards <65621465+zerico007@users.noreply.github.com> Date: Wed, 29 Mar 2023 12:40:59 -0500 Subject: [PATCH 1/6] improved typing --- src/react-to-webcomponent.ts | 118 ++++++++++++++++++++++------------- 1 file changed, 74 insertions(+), 44 deletions(-) diff --git a/src/react-to-webcomponent.ts b/src/react-to-webcomponent.ts index c552834..e0531c8 100644 --- a/src/react-to-webcomponent.ts +++ b/src/react-to-webcomponent.ts @@ -1,7 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ const renderSymbol = Symbol.for("r2wc.reactRender") const shouldRenderSymbol = Symbol.for("r2wc.shouldRender") const rootSymbol = Symbol.for("r2wc.root") +interface RefObject { + current: T | null; +} + +interface ReactElement

{ + type: T; + props: P; + key: string | number | null; +} + +interface ReactPortal extends ReactElement { + key: string | number | null; + children: ReactNode; +} + +type ReactFragment = Iterable; + type ReactNode = ReactElement | string | number | ReactFragment | ReactPortal | boolean | null | undefined; + +interface FC

{ + (props: P & { children?: ReactNode }, context?: any): ReactElement | null; + propTypes?: any; + contextTypes?: any; + defaultProps?: Partial

; + displayName?: string; +} + +interface ReactDOM { + createRoot?: (container: any, options?: any) => unknown + unmountComponentAtNode?: (obj: Record) => unknown + render?: ( + element: ReactElement | null, + container: Record, + ) => unknown + } + +interface React { + createRef: () => RefObject + createElement: ( + type: string | FC, + data: any, + children?: any, + ) => ReactElement | null + } + function toDashedStyle(camelCase = "") { return camelCase.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase() } @@ -22,23 +67,24 @@ function flattenIfOne(arr: object) { function mapChildren(React: React, node: Element) { if (node.nodeType === Node.TEXT_NODE) { - return node.textContent.toString() + return node.textContent?.toString() } - const arr = Array.from(node.childNodes).map((c: Element) => { + const arr = Array.from(node.childNodes).map((c: ChildNode) => { if (c.nodeType === Node.TEXT_NODE) { - return c.textContent.toString() + return c.textContent?.toString() } // BR = br, ReactElement = ReactElement const nodeName = isAllCaps(c.nodeName) ? c.nodeName.toLowerCase() : c.nodeName - const children = flattenIfOne(mapChildren(React, c)) + const children = flattenIfOne(mapChildren(React, c as Element)) // we need to format c.attributes before passing it to createElement - const attributes = {} - for (const attr of c.attributes) { - attributes[attr.name] = attr.value + const attributes: Record = {} + const cAsElement = c as Element; + for (const attr of cAsElement.getAttributeNames()) { + attributes[attr] = cAsElement.getAttribute(attr) } return React.createElement(nodeName, attributes, children) @@ -49,7 +95,7 @@ function mapChildren(React: React, node: Element) { const define = { // Creates a getter/setter that re-renders everytime a property is set. - expando: function (receiver: object, key: string, value: unknown) { + expando: function (receiver: Record, key: string, value: unknown) { Object.defineProperty(receiver, key, { enumerable: true, get: function () { @@ -64,24 +110,6 @@ const define = { }, } -interface React { - createRef: () => Record - createElement: ( - ReactComponent: object | string, - data: object, - children?: object, - ) => Record -} - -interface ReactDOM { - createRoot?: (container: unknown) => unknown - unmountComponentAtNode: (obj: Record) => unknown - render: ( - element: Record, - container: Record, - ) => unknown -} - interface R2WCOptions { shadow?: string | boolean props?: Array | Record @@ -98,34 +126,36 @@ interface R2WCOptions { */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export default function ( - ReactComponent: { propTypes?: object }, + ReactComponent: FC, React: React, 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 +): any { + const propTypes: Record = {} // { [camelCasedProp]: String | Number | Boolean | Function | Object | Array } + const propAttrMap: Record = {} // @TODO: add option to specify for asymetric mapping (eg "className" from "class") + const attrPropMap: Record = {} // 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] + propTypes[key] = Array.isArray(options.props) ? String : options.props?.[key] propAttrMap[key] = toDashedStyle(key) attrPropMap[propAttrMap[key]] = key }) - const renderAddedProperties = { + const renderAddedProperties: Record = { isConnected: "isConnected" in HTMLElement.prototype, } let rendering = false // Create the web component "class" - const WebComponent = function (...args) { + const WebComponent = function (this: any,...args: any[]) { const self = Reflect.construct(HTMLElement, args, this.constructor) if (typeof options.shadow === "string") { self.attachShadow({ mode: options.shadow }) @@ -151,7 +181,7 @@ export default function ( // when any undefined property is set, create a getter/setter that re-renders set: function (target, key, value, receiver) { if (rendering) { - renderAddedProperties[key] = true + renderAddedProperties[key as string] = true } if ( @@ -200,16 +230,16 @@ export default function ( this[renderSymbol]() } targetPrototype.disconnectedCallback = function () { - if (typeof ReactDOM.createRoot === "function") { + if (ReactDOM.createRoot && typeof ReactDOM.createRoot === "function") { this[rootSymbol].unmount() - } else { + } else if (ReactDOM.unmountComponentAtNode) { ReactDOM.unmountComponentAtNode(this) } } targetPrototype[renderSymbol] = function () { if (this[shouldRenderSymbol] === true) { - const data = {} - Object.keys(this).forEach(function (key) { + const data: Record = {} + Object.keys(this).forEach(function (this: any, key) { if (renderAddedProperties[key] !== false) { data[key] = this[key] } @@ -223,13 +253,13 @@ export default function ( const element = React.createElement(ReactComponent, data, children) // Use react to render element in container - if (typeof ReactDOM.createRoot === "function") { + if (ReactDOM.createRoot && typeof ReactDOM.createRoot === "function") { if (!this[rootSymbol]) { this[rootSymbol] = ReactDOM.createRoot(container) } this[rootSymbol].render(element) - } else { + } else if (ReactDOM.render) { ReactDOM.render(element, container) } @@ -242,8 +272,8 @@ export default function ( targetPrototype.attributeChangedCallback = function ( name: string, - oldValue, - newValue, + oldValue: any, + newValue: any, ) { const propertyName = attrPropMap[name] || name switch (propTypes[propertyName]) { From 33212c08acdaa19052da31e1984db6e838bf3d40 Mon Sep 17 00:00:00 2001 From: Bavin Edwards <65621465+zerico007@users.noreply.github.com> Date: Wed, 29 Mar 2023 16:32:08 -0500 Subject: [PATCH 2/6] react and react-dom mock types --- src/react-to-webcomponent.ts | 243 +++++++++++++++-------------------- src/types/global.d.ts | 57 ++++++++ 2 files changed, 160 insertions(+), 140 deletions(-) create mode 100644 src/types/global.d.ts diff --git a/src/react-to-webcomponent.ts b/src/react-to-webcomponent.ts index e0531c8..3938c73 100644 --- a/src/react-to-webcomponent.ts +++ b/src/react-to-webcomponent.ts @@ -1,118 +1,79 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -const renderSymbol = Symbol.for("r2wc.reactRender") -const shouldRenderSymbol = Symbol.for("r2wc.shouldRender") -const rootSymbol = Symbol.for("r2wc.root") +const renderSymbol = Symbol.for("r2wc.reactRender"); +const shouldRenderSymbol = Symbol.for("r2wc.shouldRender"); +const rootSymbol = Symbol.for("r2wc.root"); -interface RefObject { - current: T | null; -} - -interface ReactElement

{ - type: T; - props: P; - key: string | number | null; -} - -interface ReactPortal extends ReactElement { - key: string | number | null; - children: ReactNode; -} - -type ReactFragment = Iterable; - type ReactNode = ReactElement | string | number | ReactFragment | ReactPortal | boolean | null | undefined; - -interface FC

{ - (props: P & { children?: ReactNode }, context?: any): ReactElement | null; - propTypes?: any; - contextTypes?: any; - defaultProps?: Partial

; - displayName?: string; -} - -interface ReactDOM { - createRoot?: (container: any, options?: any) => unknown - unmountComponentAtNode?: (obj: Record) => unknown - render?: ( - element: ReactElement | null, - container: Record, - ) => unknown - } - -interface React { - createRef: () => RefObject - createElement: ( - type: string | FC, - data: any, - children?: any, - ) => ReactElement | null - } function toDashedStyle(camelCase = "") { - return camelCase.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase() + return camelCase.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); } function isAllCaps(word: string) { - return word.split("").every((c: string) => c.toUpperCase() === c) + return word.split("").every((c: string) => c.toUpperCase() === c); } function flattenIfOne(arr: object) { if (!Array.isArray(arr)) { - return arr + return arr; } if (arr.length === 1) { - return arr[0] + return arr[0]; } - return arr + return arr; } function mapChildren(React: React, node: Element) { if (node.nodeType === Node.TEXT_NODE) { - return node.textContent?.toString() + return node.textContent?.toString(); } const arr = Array.from(node.childNodes).map((c: ChildNode) => { if (c.nodeType === Node.TEXT_NODE) { - return c.textContent?.toString() + return c.textContent?.toString(); } // BR = br, ReactElement = ReactElement const nodeName = isAllCaps(c.nodeName) ? c.nodeName.toLowerCase() - : c.nodeName - const children = flattenIfOne(mapChildren(React, c as Element)) + : c.nodeName; + const children = flattenIfOne(mapChildren(React, c as Element)); // we need to format c.attributes before passing it to createElement - const attributes: Record = {} + const attributes: Record = {}; const cAsElement = c as Element; for (const attr of cAsElement.getAttributeNames()) { - attributes[attr] = cAsElement.getAttribute(attr) + attributes[attr] = cAsElement.getAttribute(attr); } - return React.createElement(nodeName, attributes, children) - }) + return React.createElement(nodeName, attributes, children); + }); - return flattenIfOne(arr) + return flattenIfOne(arr); } const define = { // Creates a getter/setter that re-renders everytime a property is set. - expando: function (receiver: Record, key: string, value: unknown) { + expando: function ( + receiver: Record, + key: string, + value: unknown + ) { Object.defineProperty(receiver, key, { enumerable: true, get: function () { - return value + return value; }, set: function (newValue) { - value = newValue - this[renderSymbol]() + value = newValue; + this[renderSymbol](); }, - }) - receiver[renderSymbol]() + }); + receiver[renderSymbol](); }, -} +}; interface R2WCOptions { - shadow?: string | boolean - props?: Array | Record + shadow?: string | boolean; + props?: Array | Record; } /** @@ -129,59 +90,61 @@ export default function ( ReactComponent: FC, React: React, ReactDOM: ReactDOM, - options: R2WCOptions = {}, + options: R2WCOptions = {} ): any { - const propTypes: Record = {} // { [camelCasedProp]: String | Number | Boolean | Function | Object | Array } - const propAttrMap: Record = {} // @TODO: add option to specify for asymetric mapping (eg "className" from "class") - const attrPropMap: Record = {} // cached inverse of propAttrMap + const propTypes: Record = {}; // { [camelCasedProp]: String | Number | Boolean | Function | Object | Array } + const propAttrMap: Record = {}; // @TODO: add option to specify for asymetric mapping (eg "className" from "class") + const attrPropMap: Record = {}; // 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) - + : Object.keys(options.props); + propKeys.forEach((key) => { - propTypes[key] = Array.isArray(options.props) ? String : options.props?.[key] - propAttrMap[key] = toDashedStyle(key) - attrPropMap[propAttrMap[key]] = key - }) + propTypes[key] = Array.isArray(options.props) + ? String + : options.props?.[key]; + propAttrMap[key] = toDashedStyle(key); + attrPropMap[propAttrMap[key]] = key; + }); const renderAddedProperties: Record = { isConnected: "isConnected" in HTMLElement.prototype, - } - let rendering = false + }; + let rendering = false; // Create the web component "class" - const WebComponent = function (this: any,...args: any[]) { - const self = Reflect.construct(HTMLElement, args, this.constructor) + const WebComponent = function (this: any, ...args: any[]) { + const self = Reflect.construct(HTMLElement, args, this.constructor); if (typeof options.shadow === "string") { - self.attachShadow({ mode: options.shadow }) + self.attachShadow({ mode: options.shadow }); } else if (options.shadow) { console.warn( - 'Specifying the "shadow" option as a boolean is deprecated and will be removed in a future version.', - ) - self.attachShadow({ mode: "open" }) + 'Specifying the "shadow" option as a boolean is deprecated and will be removed in a future version.' + ); + self.attachShadow({ mode: "open" }); } - return self - } + return self; + }; // Make the class extend HTMLElement - const targetPrototype = Object.create(HTMLElement.prototype) - targetPrototype.constructor = WebComponent + const targetPrototype = Object.create(HTMLElement.prototype); + targetPrototype.constructor = WebComponent; // But have that prototype be wrapped in a proxy. const proxyPrototype = new Proxy(targetPrototype, { has: function (target, key) { - return key in propTypes || key in targetPrototype + return key in propTypes || key in targetPrototype; }, // when any undefined property is set, create a getter/setter that re-renders set: function (target, key, value, receiver) { if (rendering) { - renderAddedProperties[key as string] = true + renderAddedProperties[key as string] = true; } if ( @@ -195,20 +158,20 @@ export default function ( key in ReactComponent.propTypes && typeof key === "string" ) { - define.expando(receiver, key, value) + define.expando(receiver, key, value); } // Set it on the HTML element as well. - return Reflect.set(target, key, value, receiver) + return Reflect.set(target, key, value, receiver); } else { - define.expando(receiver, key, value) + define.expando(receiver, key, value); } - return true + return true; }, // makes sure the property looks writable getOwnPropertyDescriptor: function (target, key) { - const own = Reflect.getOwnPropertyDescriptor(target, key) + const own = Reflect.getOwnPropertyDescriptor(target, key); if (own) { - return own + return own; } if (key in propTypes) { return { @@ -216,99 +179,99 @@ export default function ( enumerable: true, writable: true, value: undefined, - } + }; } }, - }) - WebComponent.prototype = proxyPrototype + }); + WebComponent.prototype = proxyPrototype; // Setup lifecycle methods targetPrototype.connectedCallback = function () { // Once connected, it will keep updating the innerHTML. // We could add a render method to allow this as well. - this[shouldRenderSymbol] = true - this[renderSymbol]() - } + this[shouldRenderSymbol] = true; + this[renderSymbol](); + }; targetPrototype.disconnectedCallback = function () { if (ReactDOM.createRoot && typeof ReactDOM.createRoot === "function") { - this[rootSymbol].unmount() + this[rootSymbol].unmount(); } else if (ReactDOM.unmountComponentAtNode) { - ReactDOM.unmountComponentAtNode(this) + ReactDOM.unmountComponentAtNode(this); } - } + }; targetPrototype[renderSymbol] = function () { if (this[shouldRenderSymbol] === true) { - const data: Record = {} + const data: Record = {}; Object.keys(this).forEach(function (this: any, key) { if (renderAddedProperties[key] !== false) { - data[key] = this[key] + data[key] = this[key]; } - }, this) - rendering = true + }, this); + rendering = true; // Container is either shadow DOM or light DOM depending on `shadow` option. - const container = options.shadow ? this.shadowRoot : this + const container = options.shadow ? this.shadowRoot : this; - const children = flattenIfOne(mapChildren(React, this)) + const children = flattenIfOne(mapChildren(React, this)); - const element = React.createElement(ReactComponent, data, children) + const element = React.createElement(ReactComponent, data, children); // Use react to render element in container if (ReactDOM.createRoot && typeof ReactDOM.createRoot === "function") { if (!this[rootSymbol]) { - this[rootSymbol] = ReactDOM.createRoot(container) + this[rootSymbol] = ReactDOM.createRoot(container); } - this[rootSymbol].render(element) + this[rootSymbol].render(element); } else if (ReactDOM.render) { - ReactDOM.render(element, container) + ReactDOM.render(element, container); } - rendering = false + rendering = false; } - } + }; // Handle attributes changing - WebComponent.observedAttributes = Object.keys(attrPropMap) + WebComponent.observedAttributes = Object.keys(attrPropMap); targetPrototype.attributeChangedCallback = function ( name: string, oldValue: any, - newValue: any, + newValue: any ) { - const propertyName = attrPropMap[name] || name + const propertyName = attrPropMap[name] || name; switch (propTypes[propertyName]) { case "ref": case Function: if (!newValue && propTypes[propertyName] === "ref") { - newValue = React.createRef() - break + newValue = React.createRef(); + break; } if (typeof window !== "undefined") { - newValue = window[newValue] || newValue + newValue = window[newValue] || newValue; } else if (typeof global !== "undefined") { - newValue = global[newValue] || newValue + newValue = global[newValue] || newValue; } if (typeof newValue === "function") { - newValue = newValue.bind(this) // this = instance of the WebComponent / HTMLElement + newValue = newValue.bind(this); // this = instance of the WebComponent / HTMLElement } - break + break; case Number: - newValue = parseFloat(newValue) - break + newValue = parseFloat(newValue); + break; case Boolean: - newValue = /^[ty1-9]/i.test(newValue) - break + newValue = /^[ty1-9]/i.test(newValue); + break; case Object: case Array: - newValue = JSON.parse(newValue) - break + newValue = JSON.parse(newValue); + break; case String: default: - break + break; } - this[propertyName] = newValue - } + this[propertyName] = newValue; + }; - return WebComponent + return WebComponent; } diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 0000000..e4f8541 --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +interface RefObject { + current: T | null +} + +interface ReactElement

{ + type: T + props: P + key: string | number | null +} + +interface ReactPortal extends ReactElement { + key: string | number | null + children: ReactNode +} + +type ReactFragment = Iterable +type ReactNode = + | ReactElement + | string + | number + | ReactFragment + | ReactPortal + | boolean + | null + | undefined + +interface FC

> { + (props: P & { children?: ReactNode }, context?: any): ReactElement< + any, + any + > | null + propTypes?: any + contextTypes?: any + defaultProps?: Partial

+ displayName?: string +} + +type Container = Element | Document | DocumentFragment + +interface ReactDOM { + createRoot?: (container: Element | DocumentFragment, options?: any) => unknown + unmountComponentAtNode?: (container: Element | DocumentFragment) => boolean + render?: ( + element: ReactElement | null | any, + container: Container | null, + ) => unknown +} + +interface React { + createRef: () => RefObject + createElement: ( + type: string | FC, + data: any, + children?: any, + ) => ReactElement | null | any +} From d6798d98cac3d824f348fb213f2e0f59d62efa53 Mon Sep 17 00:00:00 2001 From: Bavin Edwards <65621465+zerico007@users.noreply.github.com> Date: Wed, 29 Mar 2023 16:39:31 -0500 Subject: [PATCH 3/6] prettier formatting --- src/react-to-webcomponent.ts | 193 +++++++++++++++++------------------ 1 file changed, 96 insertions(+), 97 deletions(-) diff --git a/src/react-to-webcomponent.ts b/src/react-to-webcomponent.ts index 3938c73..da5c3d3 100644 --- a/src/react-to-webcomponent.ts +++ b/src/react-to-webcomponent.ts @@ -1,53 +1,52 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -const renderSymbol = Symbol.for("r2wc.reactRender"); -const shouldRenderSymbol = Symbol.for("r2wc.shouldRender"); -const rootSymbol = Symbol.for("r2wc.root"); - +const renderSymbol = Symbol.for("r2wc.reactRender") +const shouldRenderSymbol = Symbol.for("r2wc.shouldRender") +const rootSymbol = Symbol.for("r2wc.root") function toDashedStyle(camelCase = "") { - return camelCase.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); + return camelCase.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase() } function isAllCaps(word: string) { - return word.split("").every((c: string) => c.toUpperCase() === c); + return word.split("").every((c: string) => c.toUpperCase() === c) } function flattenIfOne(arr: object) { if (!Array.isArray(arr)) { - return arr; + return arr } if (arr.length === 1) { - return arr[0]; + return arr[0] } - return arr; + return arr } function mapChildren(React: React, node: Element) { if (node.nodeType === Node.TEXT_NODE) { - return node.textContent?.toString(); + return node.textContent?.toString() } const arr = Array.from(node.childNodes).map((c: ChildNode) => { if (c.nodeType === Node.TEXT_NODE) { - return c.textContent?.toString(); + return c.textContent?.toString() } // BR = br, ReactElement = ReactElement const nodeName = isAllCaps(c.nodeName) ? c.nodeName.toLowerCase() - : c.nodeName; - const children = flattenIfOne(mapChildren(React, c as Element)); + : c.nodeName + const children = flattenIfOne(mapChildren(React, c as Element)) // we need to format c.attributes before passing it to createElement - const attributes: Record = {}; - const cAsElement = c as Element; + const attributes: Record = {} + const cAsElement = c as Element for (const attr of cAsElement.getAttributeNames()) { - attributes[attr] = cAsElement.getAttribute(attr); + attributes[attr] = cAsElement.getAttribute(attr) } - return React.createElement(nodeName, attributes, children); - }); + return React.createElement(nodeName, attributes, children) + }) - return flattenIfOne(arr); + return flattenIfOne(arr) } const define = { @@ -55,25 +54,25 @@ const define = { expando: function ( receiver: Record, key: string, - value: unknown + value: unknown, ) { Object.defineProperty(receiver, key, { enumerable: true, get: function () { - return value; + return value }, set: function (newValue) { - value = newValue; - this[renderSymbol](); + value = newValue + this[renderSymbol]() }, - }); - receiver[renderSymbol](); + }) + receiver[renderSymbol]() }, -}; +} interface R2WCOptions { - shadow?: string | boolean; - props?: Array | Record; + shadow?: string | boolean + props?: Array | Record } /** @@ -90,61 +89,61 @@ export default function ( ReactComponent: FC, React: React, ReactDOM: ReactDOM, - options: R2WCOptions = {} + options: R2WCOptions = {}, ): any { - const propTypes: Record = {}; // { [camelCasedProp]: String | Number | Boolean | Function | Object | Array } - const propAttrMap: Record = {}; // @TODO: add option to specify for asymetric mapping (eg "className" from "class") - const attrPropMap: Record = {}; // cached inverse of propAttrMap + const propTypes: Record = {} // { [camelCasedProp]: String | Number | Boolean | Function | Object | Array } + const propAttrMap: Record = {} // @TODO: add option to specify for asymetric mapping (eg "className" from "class") + const attrPropMap: Record = {} // 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); + : Object.keys(options.props) propKeys.forEach((key) => { propTypes[key] = Array.isArray(options.props) ? String - : options.props?.[key]; - propAttrMap[key] = toDashedStyle(key); - attrPropMap[propAttrMap[key]] = key; - }); + : options.props?.[key] + propAttrMap[key] = toDashedStyle(key) + attrPropMap[propAttrMap[key]] = key + }) const renderAddedProperties: Record = { isConnected: "isConnected" in HTMLElement.prototype, - }; - let rendering = false; + } + let rendering = false // Create the web component "class" const WebComponent = function (this: any, ...args: any[]) { - const self = Reflect.construct(HTMLElement, args, this.constructor); + const self = Reflect.construct(HTMLElement, args, this.constructor) if (typeof options.shadow === "string") { - self.attachShadow({ mode: options.shadow }); + self.attachShadow({ mode: options.shadow }) } else if (options.shadow) { console.warn( - 'Specifying the "shadow" option as a boolean is deprecated and will be removed in a future version.' - ); - self.attachShadow({ mode: "open" }); + 'Specifying the "shadow" option as a boolean is deprecated and will be removed in a future version.', + ) + self.attachShadow({ mode: "open" }) } - return self; - }; + return self + } // Make the class extend HTMLElement - const targetPrototype = Object.create(HTMLElement.prototype); - targetPrototype.constructor = WebComponent; + const targetPrototype = Object.create(HTMLElement.prototype) + targetPrototype.constructor = WebComponent // But have that prototype be wrapped in a proxy. const proxyPrototype = new Proxy(targetPrototype, { has: function (target, key) { - return key in propTypes || key in targetPrototype; + return key in propTypes || key in targetPrototype }, // when any undefined property is set, create a getter/setter that re-renders set: function (target, key, value, receiver) { if (rendering) { - renderAddedProperties[key as string] = true; + renderAddedProperties[key as string] = true } if ( @@ -158,20 +157,20 @@ export default function ( key in ReactComponent.propTypes && typeof key === "string" ) { - define.expando(receiver, key, value); + define.expando(receiver, key, value) } // Set it on the HTML element as well. - return Reflect.set(target, key, value, receiver); + return Reflect.set(target, key, value, receiver) } else { - define.expando(receiver, key, value); + define.expando(receiver, key, value) } - return true; + return true }, // makes sure the property looks writable getOwnPropertyDescriptor: function (target, key) { - const own = Reflect.getOwnPropertyDescriptor(target, key); + const own = Reflect.getOwnPropertyDescriptor(target, key) if (own) { - return own; + return own } if (key in propTypes) { return { @@ -179,99 +178,99 @@ export default function ( enumerable: true, writable: true, value: undefined, - }; + } } }, - }); - WebComponent.prototype = proxyPrototype; + }) + WebComponent.prototype = proxyPrototype // Setup lifecycle methods targetPrototype.connectedCallback = function () { // Once connected, it will keep updating the innerHTML. // We could add a render method to allow this as well. - this[shouldRenderSymbol] = true; - this[renderSymbol](); - }; + this[shouldRenderSymbol] = true + this[renderSymbol]() + } targetPrototype.disconnectedCallback = function () { if (ReactDOM.createRoot && typeof ReactDOM.createRoot === "function") { - this[rootSymbol].unmount(); + this[rootSymbol].unmount() } else if (ReactDOM.unmountComponentAtNode) { - ReactDOM.unmountComponentAtNode(this); + ReactDOM.unmountComponentAtNode(this) } - }; + } targetPrototype[renderSymbol] = function () { if (this[shouldRenderSymbol] === true) { - const data: Record = {}; + const data: Record = {} Object.keys(this).forEach(function (this: any, key) { if (renderAddedProperties[key] !== false) { - data[key] = this[key]; + data[key] = this[key] } - }, this); - rendering = true; + }, this) + rendering = true // Container is either shadow DOM or light DOM depending on `shadow` option. - const container = options.shadow ? this.shadowRoot : this; + const container = options.shadow ? this.shadowRoot : this - const children = flattenIfOne(mapChildren(React, this)); + const children = flattenIfOne(mapChildren(React, this)) - const element = React.createElement(ReactComponent, data, children); + const element = React.createElement(ReactComponent, data, children) // Use react to render element in container if (ReactDOM.createRoot && typeof ReactDOM.createRoot === "function") { if (!this[rootSymbol]) { - this[rootSymbol] = ReactDOM.createRoot(container); + this[rootSymbol] = ReactDOM.createRoot(container) } - this[rootSymbol].render(element); + this[rootSymbol].render(element) } else if (ReactDOM.render) { - ReactDOM.render(element, container); + ReactDOM.render(element, container) } - rendering = false; + rendering = false } - }; + } // Handle attributes changing - WebComponent.observedAttributes = Object.keys(attrPropMap); + WebComponent.observedAttributes = Object.keys(attrPropMap) targetPrototype.attributeChangedCallback = function ( name: string, oldValue: any, - newValue: any + newValue: any, ) { - const propertyName = attrPropMap[name] || name; + const propertyName = attrPropMap[name] || name switch (propTypes[propertyName]) { case "ref": case Function: if (!newValue && propTypes[propertyName] === "ref") { - newValue = React.createRef(); - break; + newValue = React.createRef() + break } if (typeof window !== "undefined") { - newValue = window[newValue] || newValue; + newValue = window[newValue] || newValue } else if (typeof global !== "undefined") { - newValue = global[newValue] || newValue; + newValue = global[newValue] || newValue } if (typeof newValue === "function") { - newValue = newValue.bind(this); // this = instance of the WebComponent / HTMLElement + newValue = newValue.bind(this) // this = instance of the WebComponent / HTMLElement } - break; + break case Number: - newValue = parseFloat(newValue); - break; + newValue = parseFloat(newValue) + break case Boolean: - newValue = /^[ty1-9]/i.test(newValue); - break; + newValue = /^[ty1-9]/i.test(newValue) + break case Object: case Array: - newValue = JSON.parse(newValue); - break; + newValue = JSON.parse(newValue) + break case String: default: - break; + break } - this[propertyName] = newValue; - }; + this[propertyName] = newValue + } - return WebComponent; + return WebComponent } From 3a027f56bff341d7e1d9af1adf6546c89c186e19 Mon Sep 17 00:00:00 2001 From: Bavin Edwards <65621465+zerico007@users.noreply.github.com> Date: Wed, 29 Mar 2023 16:55:40 -0500 Subject: [PATCH 4/6] slight typing changes --- src/react-to-webcomponent.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/react-to-webcomponent.ts b/src/react-to-webcomponent.ts index da5c3d3..ffbdb4e 100644 --- a/src/react-to-webcomponent.ts +++ b/src/react-to-webcomponent.ts @@ -26,7 +26,7 @@ function mapChildren(React: React, node: Element) { return node.textContent?.toString() } - const arr = Array.from(node.childNodes).map((c: ChildNode) => { + const arr = Array.from(node.childNodes as unknown as Element[]).map((c: Element) => { if (c.nodeType === Node.TEXT_NODE) { return c.textContent?.toString() } @@ -34,13 +34,12 @@ function mapChildren(React: React, node: Element) { const nodeName = isAllCaps(c.nodeName) ? c.nodeName.toLowerCase() : c.nodeName - const children = flattenIfOne(mapChildren(React, c as Element)) + const children = flattenIfOne(mapChildren(React, c)) // we need to format c.attributes before passing it to createElement const attributes: Record = {} - const cAsElement = c as Element - for (const attr of cAsElement.getAttributeNames()) { - attributes[attr] = cAsElement.getAttribute(attr) + for (const attr of c.getAttributeNames()) { + attributes[attr] = c.getAttribute(attr) } return React.createElement(nodeName, attributes, children) From 1a8820d4790420f3955a4dbdc06f399bdf6b0e86 Mon Sep 17 00:00:00 2001 From: Bavin Edwards <65621465+zerico007@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:30:28 -0500 Subject: [PATCH 5/6] specific return type for r2wc function --- src/react-to-webcomponent.ts | 46 ++++++++++++++++++++---------------- src/types/global.d.ts | 4 ++++ 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/react-to-webcomponent.ts b/src/react-to-webcomponent.ts index ffbdb4e..b3819af 100644 --- a/src/react-to-webcomponent.ts +++ b/src/react-to-webcomponent.ts @@ -26,24 +26,26 @@ function mapChildren(React: React, node: Element) { return node.textContent?.toString() } - const arr = Array.from(node.childNodes as unknown as Element[]).map((c: Element) => { - if (c.nodeType === Node.TEXT_NODE) { - return c.textContent?.toString() - } - // BR = br, ReactElement = ReactElement - const nodeName = isAllCaps(c.nodeName) - ? c.nodeName.toLowerCase() - : c.nodeName - const children = flattenIfOne(mapChildren(React, c)) + const arr = Array.from(node.childNodes as unknown as Element[]).map( + (c: Element) => { + if (c.nodeType === Node.TEXT_NODE) { + return c.textContent?.toString() + } + // BR = br, ReactElement = ReactElement + const nodeName = isAllCaps(c.nodeName) + ? c.nodeName.toLowerCase() + : c.nodeName + const children = flattenIfOne(mapChildren(React, c)) - // we need to format c.attributes before passing it to createElement - const attributes: Record = {} - for (const attr of c.getAttributeNames()) { - attributes[attr] = c.getAttribute(attr) - } + // we need to format c.attributes before passing it to createElement + const attributes: Record = {} + for (const attr of c.getAttributeNames()) { + attributes[attr] = c.getAttribute(attr) + } - return React.createElement(nodeName, attributes, children) - }) + return React.createElement(nodeName, attributes, children) + }, + ) return flattenIfOne(arr) } @@ -89,7 +91,7 @@ export default function ( React: React, ReactDOM: ReactDOM, options: R2WCOptions = {}, -): any { +): CustomElementConstructor { const propTypes: Record = {} // { [camelCasedProp]: String | Number | Boolean | Function | Object | Array } const propAttrMap: Record = {} // @TODO: add option to specify for asymetric mapping (eg "className" from "class") const attrPropMap: Record = {} // cached inverse of propAttrMap @@ -117,9 +119,13 @@ export default function ( let rendering = false // Create the web component "class" const WebComponent = function (this: any, ...args: any[]) { - const self = Reflect.construct(HTMLElement, args, this.constructor) + const self: HTMLElement = Reflect.construct( + HTMLElement, + args, + this.constructor, + ) if (typeof options.shadow === "string") { - self.attachShadow({ mode: options.shadow }) + self.attachShadow({ mode: options.shadow } as ShadowRoot) } else if (options.shadow) { console.warn( 'Specifying the "shadow" option as a boolean is deprecated and will be removed in a future version.', @@ -271,5 +277,5 @@ export default function ( this[propertyName] = newValue } - return WebComponent + return WebComponent as unknown as CustomElementConstructor } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index e4f8541..868e0c2 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -55,3 +55,7 @@ interface React { children?: any, ) => ReactElement | null | any } + +interface CustomElementConstructor { + new (...params: any[]): HTMLElement; + } \ No newline at end of file From 24ed867c0334ad9335e6b9f996dddf5c442b8dd4 Mon Sep 17 00:00:00 2001 From: Bavin Edwards <65621465+zerico007@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:48:31 -0500 Subject: [PATCH 6/6] type support for class components --- src/react-to-webcomponent.ts | 2 +- src/types/global.d.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/react-to-webcomponent.ts b/src/react-to-webcomponent.ts index b3819af..b187463 100644 --- a/src/react-to-webcomponent.ts +++ b/src/react-to-webcomponent.ts @@ -87,7 +87,7 @@ interface R2WCOptions { */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export default function ( - ReactComponent: FC, + ReactComponent: FC | ComponentClass, React: React, ReactDOM: ReactDOM, options: R2WCOptions = {}, diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 868e0c2..6466264 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -36,6 +36,16 @@ interface FC

> { displayName?: string } +interface ComponentClass

> { + new (props: P, context?: any): any + propTypes?: any; + contextType?: any; + contextTypes?: any; + childContextTypes?: any; + defaultProps?: Partial

| undefined; + displayName?: string | undefined; + } + type Container = Element | Document | DocumentFragment interface ReactDOM { @@ -50,7 +60,7 @@ interface ReactDOM { interface React { createRef: () => RefObject createElement: ( - type: string | FC, + type: string | FC | ComponentClass, data: any, children?: any, ) => ReactElement | null | any