Skip to content

Commit 103ba23

Browse files
committed
Allow arbitrary containerElement type
1 parent f526aa1 commit 103ba23

File tree

3 files changed

+66
-24
lines changed

3 files changed

+66
-24
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ An optional options object parameter can be passed to configure the node.
133133

134134
### `portals.createSvgPortalNode([options])`
135135

136-
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 `<g>` instead of a `<div>`.
136+
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 `<g>` instead of a `<div>` by default, which can be customized by `options.containerElement`.
137137

138138
An error will be thrown if you attempt to use a HTML node for SVG content, or a SVG node for HTML content.
139139

src/index.tsx

+23-22
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,19 @@ import * as React from 'react';
22
import * as ReactDOM from 'react-dom';
33

44
// Internally, the portalNode must be for either HTML or SVG elements
5-
const ELEMENT_TYPE_HTML_BLOCK = 'div';
6-
const ELEMENT_TYPE_HTML_INLINE = 'span';
5+
const ELEMENT_TYPE_HTML = 'html';
76
const ELEMENT_TYPE_SVG = 'svg';
87

9-
type ANY_ELEMENT_TYPE = typeof ELEMENT_TYPE_HTML_BLOCK | typeof ELEMENT_TYPE_HTML_INLINE | typeof ELEMENT_TYPE_SVG;
10-
118
type BaseOptions = {
129
attributes?: { [key: string]: string };
1310
};
1411

1512
type HtmlOptions = BaseOptions & {
16-
containerElement?: typeof ELEMENT_TYPE_HTML_BLOCK | typeof ELEMENT_TYPE_HTML_INLINE;
13+
containerElement?: keyof HTMLElementTagNameMap;
1714
};
1815

1916
type SvgOptions = BaseOptions & {
20-
containerElement?: typeof ELEMENT_TYPE_SVG;
17+
containerElement?: keyof SVGElementTagNameMap;
2118
};
2219

2320
type Options = HtmlOptions | SvgOptions;
@@ -45,7 +42,7 @@ interface PortalNodeBase<C extends Component<any>> {
4542
}
4643
export interface HtmlPortalNode<C extends Component<any> = Component<any>> extends PortalNodeBase<C> {
4744
element: HTMLElement;
48-
elementType: typeof ELEMENT_TYPE_HTML_BLOCK | typeof ELEMENT_TYPE_HTML_INLINE;
45+
elementType: typeof ELEMENT_TYPE_HTML;
4946
}
5047
export interface SvgPortalNode<C extends Component<any> = Component<any>> extends PortalNodeBase<C> {
5148
element: SVGElement;
@@ -54,15 +51,14 @@ export interface SvgPortalNode<C extends Component<any> = Component<any>> extend
5451
type AnyPortalNode<C extends Component<any> = Component<any>> = HtmlPortalNode<C> | SvgPortalNode<C>;
5552

5653

57-
const validateElementType = (domElement: Element, elementType: ANY_ELEMENT_TYPE) => {
54+
const validateElementType = (domElement: Element, elementType: typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG) => {
5855
const ownerDocument = (domElement.ownerDocument ?? document) as any;
5956
// Cast document to `any` because Typescript doesn't know about the legacy `Document.parentWindow` field, and also
6057
// doesn't believe `Window.HTMLElement`/`Window.SVGElement` can be used in instanceof tests.
6158
const ownerWindow = ownerDocument.defaultView ?? ownerDocument.parentWindow ?? window; // `parentWindow` for IE8 and earlier
6259

6360
switch (elementType) {
64-
case ELEMENT_TYPE_HTML_BLOCK:
65-
case ELEMENT_TYPE_HTML_INLINE:
61+
case ELEMENT_TYPE_HTML:
6662
return domElement instanceof ownerWindow.HTMLElement;
6763
case ELEMENT_TYPE_SVG:
6864
return domElement instanceof ownerWindow.SVGElement;
@@ -73,10 +69,9 @@ const validateElementType = (domElement: Element, elementType: ANY_ELEMENT_TYPE)
7369

7470
// This is the internal implementation: the public entry points set elementType to an appropriate value
7571
const createPortalNode = <C extends Component<any>>(
76-
defaultElementType: ANY_ELEMENT_TYPE,
72+
elementType: typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG,
7773
options?: Options
7874
): AnyPortalNode<C> => {
79-
const elementType = options?.containerElement ?? defaultElementType;
8075
let initialProps = {} as ComponentProps<C>;
8176

8277
let parent: Node | undefined;
@@ -85,15 +80,14 @@ const createPortalNode = <C extends Component<any>>(
8580
let element;
8681

8782
switch (elementType) {
88-
case ELEMENT_TYPE_HTML_BLOCK:
89-
case ELEMENT_TYPE_HTML_INLINE:
90-
element = document.createElement(elementType);
83+
case ELEMENT_TYPE_HTML:
84+
element = document.createElement(options?.containerElement ?? 'div');
9185
break;
9286
case ELEMENT_TYPE_SVG:
93-
element = document.createElementNS(SVG_NAMESPACE, 'g');
87+
element = document.createElementNS(SVG_NAMESPACE, options?.containerElement ?? 'g');
9488
break;
9589
default:
96-
throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "div", "span" or "svg".`);
90+
throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "html" or "svg".`);
9791
}
9892

9993
if (options && typeof options === "object" && options.attributes) {
@@ -256,14 +250,21 @@ class OutPortal<C extends Component<any>> extends React.PureComponent<OutPortalP
256250
render() {
257251
// Render a placeholder to the DOM, so we can get a reference into
258252
// our location in the DOM, and swap it out for the portaled node.
259-
// A <span> placeholder:
260-
// - prevents invalid HTML (e.g. inside <p>)
261-
// - works fine even for SVG.
262-
return <span ref={this.placeholderNode} />;
253+
const tagName = this.props.node.element.tagName;
254+
255+
// SVG tagName is lowercase and case sensitive, HTML is uppercase and case insensitive.
256+
// React.createElement expects lowercase first letter to treat as non-component element.
257+
// (Passing uppercase type won't break anything, but React warns otherwise:)
258+
// https://github.com/facebook/react/blob/8039f1b2a05d00437cd29707761aeae098c80adc/CHANGELOG.md?plain=1#L1984
259+
const type = this.props.node.elementType === ELEMENT_TYPE_HTML
260+
? tagName.toLowerCase()
261+
: tagName;
262+
263+
return React.createElement(type, { ref: this.placeholderNode });
263264
}
264265
}
265266

266-
const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML_BLOCK) as
267+
const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML) as
267268
<C extends Component<any> = Component<any>>(options?: HtmlOptions) => HtmlPortalNode<C>;
268269
const createSvgPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_SVG) as
269270
<C extends Component<any> = Component<any>>(options?: SvgOptions) => SvgPortalNode<C>;

stories/html.stories.js

+42-1
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ storiesOf('Portals', module)
289289
</div>
290290
});
291291
})
292-
.add('can render inline portal', () => {
292+
.add('portal container element as span in paragraph', () => {
293293
const portalNode = createHtmlPortalNode({ containerElement: 'span' });
294294

295295
return <div>
@@ -306,6 +306,47 @@ storiesOf('Portals', module)
306306
</p>
307307
</div>;
308308
})
309+
.add("portal container element as tr", () => {
310+
const portalNode = createHtmlPortalNode({ containerElement: "tr" });
311+
312+
return React.createElement(() => {
313+
const [useFirstTable, setUseFirstTable] = React.useState(true);
314+
315+
return (
316+
<div>
317+
<InPortal node={portalNode}>
318+
<td>Cell 1</td>
319+
<td>Cell 2</td>
320+
<td>Cell 3</td>
321+
</InPortal>
322+
323+
<button onClick={() => setUseFirstTable(!useFirstTable)}>
324+
Move row to {useFirstTable ? "second" : "first"} table
325+
</button>
326+
327+
<div style={{ display: "flex", gap: "20px", marginTop: "20px" }}>
328+
<table border="1">
329+
<thead>
330+
<tr>
331+
<th colSpan="3">First Table</th>
332+
</tr>
333+
</thead>
334+
<tbody>{useFirstTable && <OutPortal node={portalNode} />}</tbody>
335+
</table>
336+
337+
<table border="1">
338+
<thead>
339+
<tr>
340+
<th colSpan="3">Second Table</th>
341+
</tr>
342+
</thead>
343+
<tbody>{!useFirstTable && <OutPortal node={portalNode} />}</tbody>
344+
</table>
345+
</div>
346+
</div>
347+
);
348+
});
349+
})
309350
.add('Example from README', () => {
310351
const MyExpensiveComponent = () => 'expensive!';
311352

0 commit comments

Comments
 (0)