diff --git a/README.md b/README.md index 0d49163..57a0e3e 100644 --- a/README.md +++ b/README.md @@ -115,21 +115,25 @@ How does it work under the hood? This creates a detached DOM node, with a little extra functionality attached to allow transmitting props later on. -This node will contain your portal contents later, within a `
`, and will eventually be attached in the target location. +This node will contain your portal contents later, and will eventually be attached in the target location. -An optional options object parameter can be passed to configure the node. The only supported option is `attributes`: this can be used to set the HTML attributes (style, class, etc.) of the intermediary, like so: +An optional options object parameter can be passed to configure the node. -```javascript -const portalNode = portals.createHtmlPortalNode({ - attributes: { id: "div-1", style: "background-color: #aaf; width: 100px;" } -}); -``` +- `options.containerElement` (default: `div`) can be set to `'span'` to ensure valid HTML (avoid React hydration warnings) when portaling into [phrasing content](https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#phrasing_content). + +- `options.attributes` can be used to set the HTML attributes (style, class, etc.) of the intermediary, like so: -The div's DOM node is also available at `.element`, so you can mutate that directly with the standard DOM APIs if preferred. + ```javascript + const portalNode = portals.createHtmlPortalNode({ + attributes: { id: "div-1", style: "background-color: #aaf; width: 100px;" } + }); + ``` + + The detached DOM node is also available at `.element`, so you can mutate that directly with the standard DOM APIs if preferred. ### `portals.createSvgPortalNode([options])` -This creates a detached SVG DOM node. It works identically to the node from `createHtmlPortalNode`, except it will work with SVG elements. Content is placed within a `` instead of a `
`. +This creates a detached SVG DOM node. It works identically to the node from `createHtmlPortalNode`, except it will work with SVG elements. Content is placed within a `` instead of a `
` by default, which can be customized by `options.containerElement`. An error will be thrown if you attempt to use a HTML node for SVG content, or a SVG node for HTML content. diff --git a/src/index.tsx b/src/index.tsx index 7c2fa0e..531a573 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,12 +5,20 @@ import * as ReactDOM from 'react-dom'; const ELEMENT_TYPE_HTML = 'html'; const ELEMENT_TYPE_SVG = 'svg'; -type ANY_ELEMENT_TYPE = typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG; +type BaseOptions = { + attributes?: { [key: string]: string }; +}; + +type HtmlOptions = BaseOptions & { + containerElement?: keyof HTMLElementTagNameMap; +}; -type Options = { - attributes: { [key: string]: string }; +type SvgOptions = BaseOptions & { + containerElement?: keyof SVGElementTagNameMap; }; +type Options = HtmlOptions | SvgOptions; + // ReactDOM can handle several different namespaces, but they're not exported publicly // https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/shared/DOMNamespaces.js#L8-L10 const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; @@ -43,23 +51,25 @@ export interface SvgPortalNode = Component> extend type AnyPortalNode = Component> = HtmlPortalNode | SvgPortalNode; -const validateElementType = (domElement: Element, elementType: ANY_ELEMENT_TYPE) => { +const validateElementType = (domElement: Element, elementType: typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG) => { const ownerDocument = (domElement.ownerDocument ?? document) as any; // Cast document to `any` because Typescript doesn't know about the legacy `Document.parentWindow` field, and also // doesn't believe `Window.HTMLElement`/`Window.SVGElement` can be used in instanceof tests. const ownerWindow = ownerDocument.defaultView ?? ownerDocument.parentWindow ?? window; // `parentWindow` for IE8 and earlier - if (elementType === ELEMENT_TYPE_HTML) { - return domElement instanceof ownerWindow.HTMLElement; - } - if (elementType === ELEMENT_TYPE_SVG) { - return domElement instanceof ownerWindow.SVGElement; + + switch (elementType) { + case ELEMENT_TYPE_HTML: + return domElement instanceof ownerWindow.HTMLElement; + case ELEMENT_TYPE_SVG: + return domElement instanceof ownerWindow.SVGElement; + default: + throw new Error(`Unrecognized element type "${elementType}" for validateElementType.`); } - throw new Error(`Unrecognized element type "${elementType}" for validateElementType.`); }; // This is the internal implementation: the public entry points set elementType to an appropriate value const createPortalNode = >( - elementType: ANY_ELEMENT_TYPE, + elementType: typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG, options?: Options ): AnyPortalNode => { let initialProps = {} as ComponentProps; @@ -68,15 +78,19 @@ const createPortalNode = >( let lastPlaceholder: Node | undefined; let element; - if (elementType === ELEMENT_TYPE_HTML) { - element= document.createElement('div'); - } else if (elementType === ELEMENT_TYPE_SVG){ - element= document.createElementNS(SVG_NAMESPACE, 'g'); - } else { - throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "html" or "svg".`); + + switch (elementType) { + case ELEMENT_TYPE_HTML: + element = document.createElement(options?.containerElement ?? 'div'); + break; + case ELEMENT_TYPE_SVG: + element = document.createElementNS(SVG_NAMESPACE, options?.containerElement ?? 'g'); + break; + default: + throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "html" or "svg".`); } - if (options && typeof options === "object") { + if (options && typeof options === "object" && options.attributes) { for (const [key, value] of Object.entries(options.attributes)) { element.setAttribute(key, value); } @@ -186,7 +200,7 @@ type OutPortalProps> = { class OutPortal> extends React.PureComponent> { - private placeholderNode = React.createRef(); + private placeholderNode = React.createRef(); private currentPortalNode?: AnyPortalNode; constructor(props: OutPortalProps) { @@ -236,15 +250,24 @@ class OutPortal> extends React.PureComponent placeholder works fine even for SVG. - return
; + const tagName = this.props.node.element.tagName; + + // SVG tagName is lowercase and case sensitive, HTML is uppercase and case insensitive. + // React.createElement expects lowercase first letter to treat as non-component element. + // (Passing uppercase type won't break anything, but React warns otherwise:) + // https://github.com/facebook/react/blob/8039f1b2a05d00437cd29707761aeae098c80adc/CHANGELOG.md?plain=1#L1984 + const type = this.props.node.elementType === ELEMENT_TYPE_HTML + ? tagName.toLowerCase() + : tagName; + + return React.createElement(type, { ref: this.placeholderNode }); } } const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML) as - = Component>(options?: Options) => HtmlPortalNode; + = Component>(options?: HtmlOptions) => HtmlPortalNode; const createSvgPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_SVG) as - = Component>(options?: Options) => SvgPortalNode; + = Component>(options?: SvgOptions) => SvgPortalNode; export { createHtmlPortalNode, diff --git a/stories/html.stories.js b/stories/html.stories.js index 1bf2986..098a9fe 100644 --- a/stories/html.stories.js +++ b/stories/html.stories.js @@ -2,7 +2,7 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { createHtmlPortalNode, createSvgPortalNode, InPortal, OutPortal } from '..'; +import { createHtmlPortalNode, InPortal, OutPortal } from '..'; const Container = (props) =>
@@ -289,6 +289,64 @@ storiesOf('Portals', module)
}); }) + .add('portal container element as span in paragraph', () => { + const portalNode = createHtmlPortalNode({ containerElement: 'span' }); + + return
+

+ Portal defined here: + + Hi! + +

+ +

+ Portal renders here: + +

+
; + }) + .add("portal container element as tr", () => { + const portalNode = createHtmlPortalNode({ containerElement: "tr" }); + + return React.createElement(() => { + const [useFirstTable, setUseFirstTable] = React.useState(true); + + return ( +
+ + Cell 1 + Cell 2 + Cell 3 + + + + +
+ + + + + + + {useFirstTable && } +
First Table
+ + + + + + + + {!useFirstTable && } +
Second Table
+
+
+ ); + }); + }) .add('Example from README', () => { const MyExpensiveComponent = () => 'expensive!'; diff --git a/tsconfig.json b/tsconfig.json index e0bcc89..e8280bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "strict": true, "rootDir": "./src", "declaration": true, - "declarationDir": "./dist" + "declarationDir": "./dist", + "noFallthroughCasesInSwitch": true }, "include": [ "./src/**/*.tsx"