Skip to content

Commit c66c63d

Browse files
authored
Merge pull request #45 from aeharding/span
Add inline portal node support
2 parents 5f8e4d1 + ead8903 commit c66c63d

File tree

4 files changed

+120
-34
lines changed

4 files changed

+120
-34
lines changed

Diff for: README.md

+13-9
Original file line numberDiff line numberDiff line change
@@ -115,21 +115,25 @@ How does it work under the hood?
115115

116116
This creates a detached DOM node, with a little extra functionality attached to allow transmitting props later on.
117117

118-
This node will contain your portal contents later, within a `<div>`, and will eventually be attached in the target location.
118+
This node will contain your portal contents later, and will eventually be attached in the target location.
119119

120-
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:
120+
An optional options object parameter can be passed to configure the node.
121121

122-
```javascript
123-
const portalNode = portals.createHtmlPortalNode({
124-
attributes: { id: "div-1", style: "background-color: #aaf; width: 100px;" }
125-
});
126-
```
122+
- `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).
123+
124+
- `options.attributes` can be used to set the HTML attributes (style, class, etc.) of the intermediary, like so:
127125

128-
The div's DOM node is also available at `.element`, so you can mutate that directly with the standard DOM APIs if preferred.
126+
```javascript
127+
const portalNode = portals.createHtmlPortalNode({
128+
attributes: { id: "div-1", style: "background-color: #aaf; width: 100px;" }
129+
});
130+
```
131+
132+
The detached DOM node is also available at `.element`, so you can mutate that directly with the standard DOM APIs if preferred.
129133

130134
### `portals.createSvgPortalNode([options])`
131135

132-
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`.
133137

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

Diff for: src/index.tsx

+46-23
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,20 @@ import * as ReactDOM from 'react-dom';
55
const ELEMENT_TYPE_HTML = 'html';
66
const ELEMENT_TYPE_SVG = 'svg';
77

8-
type ANY_ELEMENT_TYPE = typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG;
8+
type BaseOptions = {
9+
attributes?: { [key: string]: string };
10+
};
11+
12+
type HtmlOptions = BaseOptions & {
13+
containerElement?: keyof HTMLElementTagNameMap;
14+
};
915

10-
type Options = {
11-
attributes: { [key: string]: string };
16+
type SvgOptions = BaseOptions & {
17+
containerElement?: keyof SVGElementTagNameMap;
1218
};
1319

20+
type Options = HtmlOptions | SvgOptions;
21+
1422
// ReactDOM can handle several different namespaces, but they're not exported publicly
1523
// https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/shared/DOMNamespaces.js#L8-L10
1624
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
@@ -43,23 +51,25 @@ export interface SvgPortalNode<C extends Component<any> = Component<any>> extend
4351
type AnyPortalNode<C extends Component<any> = Component<any>> = HtmlPortalNode<C> | SvgPortalNode<C>;
4452

4553

46-
const validateElementType = (domElement: Element, elementType: ANY_ELEMENT_TYPE) => {
54+
const validateElementType = (domElement: Element, elementType: typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG) => {
4755
const ownerDocument = (domElement.ownerDocument ?? document) as any;
4856
// Cast document to `any` because Typescript doesn't know about the legacy `Document.parentWindow` field, and also
4957
// doesn't believe `Window.HTMLElement`/`Window.SVGElement` can be used in instanceof tests.
5058
const ownerWindow = ownerDocument.defaultView ?? ownerDocument.parentWindow ?? window; // `parentWindow` for IE8 and earlier
51-
if (elementType === ELEMENT_TYPE_HTML) {
52-
return domElement instanceof ownerWindow.HTMLElement;
53-
}
54-
if (elementType === ELEMENT_TYPE_SVG) {
55-
return domElement instanceof ownerWindow.SVGElement;
59+
60+
switch (elementType) {
61+
case ELEMENT_TYPE_HTML:
62+
return domElement instanceof ownerWindow.HTMLElement;
63+
case ELEMENT_TYPE_SVG:
64+
return domElement instanceof ownerWindow.SVGElement;
65+
default:
66+
throw new Error(`Unrecognized element type "${elementType}" for validateElementType.`);
5667
}
57-
throw new Error(`Unrecognized element type "${elementType}" for validateElementType.`);
5868
};
5969

6070
// This is the internal implementation: the public entry points set elementType to an appropriate value
6171
const createPortalNode = <C extends Component<any>>(
62-
elementType: ANY_ELEMENT_TYPE,
72+
elementType: typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG,
6373
options?: Options
6474
): AnyPortalNode<C> => {
6575
let initialProps = {} as ComponentProps<C>;
@@ -68,15 +78,19 @@ const createPortalNode = <C extends Component<any>>(
6878
let lastPlaceholder: Node | undefined;
6979

7080
let element;
71-
if (elementType === ELEMENT_TYPE_HTML) {
72-
element= document.createElement('div');
73-
} else if (elementType === ELEMENT_TYPE_SVG){
74-
element= document.createElementNS(SVG_NAMESPACE, 'g');
75-
} else {
76-
throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "html" or "svg".`);
81+
82+
switch (elementType) {
83+
case ELEMENT_TYPE_HTML:
84+
element = document.createElement(options?.containerElement ?? 'div');
85+
break;
86+
case ELEMENT_TYPE_SVG:
87+
element = document.createElementNS(SVG_NAMESPACE, options?.containerElement ?? 'g');
88+
break;
89+
default:
90+
throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "html" or "svg".`);
7791
}
7892

79-
if (options && typeof options === "object") {
93+
if (options && typeof options === "object" && options.attributes) {
8094
for (const [key, value] of Object.entries(options.attributes)) {
8195
element.setAttribute(key, value);
8296
}
@@ -186,7 +200,7 @@ type OutPortalProps<C extends Component<any>> = {
186200

187201
class OutPortal<C extends Component<any>> extends React.PureComponent<OutPortalProps<C>> {
188202

189-
private placeholderNode = React.createRef<HTMLDivElement>();
203+
private placeholderNode = React.createRef<HTMLElement>();
190204
private currentPortalNode?: AnyPortalNode<C>;
191205

192206
constructor(props: OutPortalProps<C>) {
@@ -236,15 +250,24 @@ class OutPortal<C extends Component<any>> extends React.PureComponent<OutPortalP
236250
render() {
237251
// Render a placeholder to the DOM, so we can get a reference into
238252
// our location in the DOM, and swap it out for the portaled node.
239-
// A <div> placeholder works fine even for SVG.
240-
return <div 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 });
241264
}
242265
}
243266

244267
const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML) as
245-
<C extends Component<any> = Component<any>>(options?: Options) => HtmlPortalNode<C>;
268+
<C extends Component<any> = Component<any>>(options?: HtmlOptions) => HtmlPortalNode<C>;
246269
const createSvgPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_SVG) as
247-
<C extends Component<any> = Component<any>>(options?: Options) => SvgPortalNode<C>;
270+
<C extends Component<any> = Component<any>>(options?: SvgOptions) => SvgPortalNode<C>;
248271

249272
export {
250273
createHtmlPortalNode,

Diff for: stories/html.stories.js

+59-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react';
22

33
import { storiesOf } from '@storybook/react';
44

5-
import { createHtmlPortalNode, createSvgPortalNode, InPortal, OutPortal } from '..';
5+
import { createHtmlPortalNode, InPortal, OutPortal } from '..';
66

77
const Container = (props) =>
88
<div style={{ "border": "1px solid #222", "padding": "10px" }}>
@@ -289,6 +289,64 @@ storiesOf('Portals', module)
289289
</div>
290290
});
291291
})
292+
.add('portal container element as span in paragraph', () => {
293+
const portalNode = createHtmlPortalNode({ containerElement: 'span' });
294+
295+
return <div>
296+
<p>
297+
Portal defined here:
298+
<InPortal node={portalNode}>
299+
Hi!
300+
</InPortal>
301+
</p>
302+
303+
<p>
304+
Portal renders here:
305+
<OutPortal node={portalNode} />
306+
</p>
307+
</div>;
308+
})
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+
})
292350
.add('Example from README', () => {
293351
const MyExpensiveComponent = () => 'expensive!';
294352

Diff for: tsconfig.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"strict": true,
99
"rootDir": "./src",
1010
"declaration": true,
11-
"declarationDir": "./dist"
11+
"declarationDir": "./dist",
12+
"noFallthroughCasesInSwitch": true
1213
},
1314
"include": [
1415
"./src/**/*.tsx"

0 commit comments

Comments
 (0)