Skip to content

Commit 96c4e51

Browse files
authored
Merge #8 to support SVG, by changing to create{Html,Svg}PortalNode
This is a breaking change, because createPortalNode is no longer supported, and will be released as v2.0.0. To migrate, just replace all v1 calls to createPortalNode with createHtmlPortalNode. Closes #2.
2 parents b633934 + 6870bb5 commit 96c4e51

File tree

4 files changed

+225
-64
lines changed

4 files changed

+225
-64
lines changed

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Create a portal node, populate it with `InPortal`, and use it somewhere with `Ou
4343
import * as portals from 'react-reverse-portal';
4444

4545
const MyComponent = (props) => {
46-
const portalNode = React.useMemo(() => portals.createPortalNode(), []);
46+
const portalNode = React.useMemo(() => portals.createHtmlPortalNode(), []);
4747

4848
return <div>
4949
{/*
@@ -104,11 +104,17 @@ Normally in `ComponentA`/`ComponentB` examples like the above, switching from `C
104104

105105
How does it work under the hood?
106106

107-
### `portals.createPortalNode`
107+
### `portals.createHtmlPortalNode`
108108

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

111-
This node will contain your portal contents later, and eventually be attached in the target location. By default it's a `div`, but you can pass your tag of choice (as a string) to override this if necessary. It's a plain DOM node, so you can mutate it to set any required props (e.g. `className`) with the standard DOM APIs.
111+
This node will contain your portal contents later, within a `<div>`, and will eventually be attached in the target location. Its plain DOM node is available at `.element`, so you can mutate that to set any required props (e.g. `className`) with the standard DOM APIs.
112+
113+
### `portals.createSvgPortalNode`
114+
115+
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>`.
116+
117+
An error will be thrown if you attempt to use a HTML node for SVG content, or a SVG node for HTML content.
112118

113119
### `portals.InPortal`
114120

src/index.tsx

Lines changed: 82 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import * as React from 'react';
22
import * as ReactDOM from 'react-dom';
33

4+
// Internally, the portalNode must be for either HTML or SVG elements
5+
const ELEMENT_TYPE_HTML = 'html';
6+
const ELEMENT_TYPE_SVG = 'svg';
7+
8+
type ANY_ELEMENT_TYPE = typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG;
9+
10+
// ReactDOM can handle several different namespaces, but they're not exported publicly
11+
// https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/shared/DOMNamespaces.js#L8-L10
12+
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
13+
414
type Component<P> = React.Component<P> | React.ComponentType<P>;
515

616
type ComponentProps<C extends Component<any>> = C extends Component<infer P> ? P : never;
717

8-
export interface PortalNode<C extends Component<any> = Component<any>> extends HTMLElement {
18+
interface PortalNodeBase<C extends Component<any>> {
919
// Used by the out portal to send props back to the real element
1020
// Hooked by InPortal to become a state update (and thus rerender)
1121
setPortalProps(p: ComponentProps<C>): void;
@@ -18,35 +28,70 @@ export interface PortalNode<C extends Component<any> = Component<any>> extends H
1828
// latest placeholder we replaced. This avoids some race conditions.
1929
unmount(expectedPlaceholder?: Node): void;
2030
}
21-
22-
interface InPortalProps {
23-
node: PortalNode;
24-
children: React.ReactNode;
31+
export interface HtmlPortalNode<C extends Component<any> = Component<any>> extends PortalNodeBase<C> {
32+
element: HTMLElement;
33+
elementType: typeof ELEMENT_TYPE_HTML;
2534
}
35+
export interface SvgPortalNode<C extends Component<any> = Component<any>> extends PortalNodeBase<C> {
36+
element: SVGElement;
37+
elementType: typeof ELEMENT_TYPE_SVG;
38+
}
39+
type AnyPortalNode<C extends Component<any> = Component<any>> = HtmlPortalNode<C> | SvgPortalNode<C>;
40+
41+
42+
const validateElementType = (domElement: Element, elementType: ANY_ELEMENT_TYPE) => {
43+
if (elementType === ELEMENT_TYPE_HTML) {
44+
return domElement instanceof HTMLElement;
45+
}
46+
if (elementType === ELEMENT_TYPE_SVG) {
47+
return domElement instanceof SVGElement;
48+
}
49+
throw new Error(`Unrecognized element type "${elementType}" for validateElementType.`);
50+
};
2651

27-
export const createPortalNode = <C extends Component<any>>(): PortalNode<C> => {
52+
// This is the internal implementation: the public entry points set elementType to an appropriate value
53+
const createPortalNode = <C extends Component<any>>(elementType: ANY_ELEMENT_TYPE): AnyPortalNode<C> => {
2854
let initialProps = {} as ComponentProps<C>;
2955

3056
let parent: Node | undefined;
3157
let lastPlaceholder: Node | undefined;
3258

33-
const portalNode = Object.assign(document.createElement('div'), {
59+
let element;
60+
if (elementType === ELEMENT_TYPE_HTML) {
61+
element= document.createElement('div');
62+
} else if (elementType === ELEMENT_TYPE_SVG){
63+
element= document.createElementNS(SVG_NAMESPACE, 'g');
64+
} else {
65+
throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "html" or "svg".`);
66+
}
67+
68+
const portalNode: AnyPortalNode<C> = {
69+
element,
70+
elementType,
3471
setPortalProps: (props: ComponentProps<C>) => {
3572
initialProps = props;
3673
},
3774
getInitialPortalProps: () => {
3875
return initialProps;
3976
},
40-
mount: (newParent: Node, newPlaceholder: Node) => {
77+
mount: (newParent: HTMLElement, newPlaceholder: HTMLElement) => {
4178
if (newPlaceholder === lastPlaceholder) {
4279
// Already mounted - noop.
4380
return;
4481
}
4582
portalNode.unmount();
4683

84+
// To support SVG and other non-html elements, the portalNode's elementType needs to match
85+
// the elementType it's being rendered into
86+
if (newParent !== parent) {
87+
if (!validateElementType(newParent, elementType)) {
88+
throw new Error(`Invalid element type for portal: "${elementType}" portalNodes must be used with ${elementType} elements, but OutPortal is within <${newParent.tagName}>.`);
89+
}
90+
}
91+
4792
newParent.replaceChild(
48-
portalNode,
49-
newPlaceholder
93+
portalNode.element,
94+
newPlaceholder,
5095
);
5196

5297
parent = newParent;
@@ -62,24 +107,29 @@ export const createPortalNode = <C extends Component<any>>(): PortalNode<C> => {
62107
if (parent && lastPlaceholder) {
63108
parent.replaceChild(
64109
lastPlaceholder,
65-
portalNode
110+
portalNode.element,
66111
);
67112

68113
parent = undefined;
69114
lastPlaceholder = undefined;
70115
}
71116
}
72-
});
117+
} as AnyPortalNode<C>;
73118

74119
return portalNode;
75120
};
76121

77-
export class InPortal extends React.PureComponent<InPortalProps, { nodeProps: {} }> {
122+
interface InPortalProps {
123+
node: AnyPortalNode;
124+
children: React.ReactNode;
125+
}
126+
127+
class InPortal extends React.PureComponent<InPortalProps, { nodeProps: {} }> {
78128

79129
constructor(props: InPortalProps) {
80130
super(props);
81131
this.state = {
82-
nodeProps: this.props.node.getInitialPortalProps()
132+
nodeProps: this.props.node.getInitialPortalProps(),
83133
};
84134
}
85135

@@ -108,19 +158,19 @@ export class InPortal extends React.PureComponent<InPortalProps, { nodeProps: {}
108158
if (!React.isValidElement(child)) return child;
109159
return React.cloneElement(child, this.state.nodeProps)
110160
}),
111-
node
161+
node.element
112162
);
113163
}
114164
}
115165

116166
type OutPortalProps<C extends Component<any>> = {
117-
node: PortalNode<C>
167+
node: AnyPortalNode<C>
118168
} & Partial<ComponentProps<C>>;
119169

120-
export class OutPortal<C extends Component<any>> extends React.PureComponent<OutPortalProps<C>> {
170+
class OutPortal<C extends Component<any>> extends React.PureComponent<OutPortalProps<C>> {
121171

122172
private placeholderNode = React.createRef<HTMLDivElement>();
123-
private currentPortalNode?: PortalNode<C>;
173+
private currentPortalNode?: AnyPortalNode<C>;
124174

125175
constructor(props: OutPortalProps<C>) {
126176
super(props);
@@ -133,7 +183,7 @@ export class OutPortal<C extends Component<any>> extends React.PureComponent<Out
133183
}
134184

135185
componentDidMount() {
136-
const node = this.props.node as PortalNode<C>;
186+
const node = this.props.node as AnyPortalNode<C>;
137187
this.currentPortalNode = node;
138188

139189
const placeholder = this.placeholderNode.current!;
@@ -145,7 +195,7 @@ export class OutPortal<C extends Component<any>> extends React.PureComponent<Out
145195
componentDidUpdate() {
146196
// We re-mount on update, just in case we were unmounted (e.g. by
147197
// a second OutPortal, which has now been removed)
148-
const node = this.props.node as PortalNode<C>;
198+
const node = this.props.node as AnyPortalNode<C>;
149199

150200
// If we're switching portal nodes, we need to clean up the current one first.
151201
if (this.currentPortalNode && node !== this.currentPortalNode) {
@@ -160,14 +210,24 @@ export class OutPortal<C extends Component<any>> extends React.PureComponent<Out
160210
}
161211

162212
componentWillUnmount() {
163-
const node = this.props.node as PortalNode<C>;
213+
const node = this.props.node as AnyPortalNode<C>;
164214
node.unmount(this.placeholderNode.current!);
165215
}
166216

167217
render() {
168218
// Render a placeholder to the DOM, so we can get a reference into
169219
// our location in the DOM, and swap it out for the portaled node.
220+
// A <div> placeholder works fine even for SVG.
170221
return <div ref={this.placeholderNode} />;
171222
}
223+
}
172224

173-
}
225+
const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML) as () => HtmlPortalNode;
226+
const createSvgPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_SVG) as () => SvgPortalNode;
227+
228+
export {
229+
createHtmlPortalNode,
230+
createSvgPortalNode,
231+
InPortal,
232+
OutPortal,
233+
}

0 commit comments

Comments
 (0)