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