Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1647e85
Add @types/events so that build runs successfully
spautz Feb 5, 2020
287a1df
Add story for failing SVG example
spautz Feb 5, 2020
7a19635
Support passing a tagName to createPortalNode
spautz Feb 5, 2020
b5edc3b
Use innerHTML to set the content for SVG elements
spautz Feb 5, 2020
cd5b3ec
Revert "Add @types/events so that build runs successfully", in order …
spautz Feb 5, 2020
e296b92
Merge remote-tracking branch 'upstream/master' into issue-2--svg-support
spautz Feb 12, 2020
b7b65f0
Create the dom element for the portal on mount, after we know what it…
spautz Feb 12, 2020
b2c8993
Cleanup
spautz Feb 12, 2020
95252b0
Handle different InPortal/OutPortal rendering order
spautz Feb 13, 2020
c849a4c
Orchestrate InPortal/OutPortal render order via a callbackFn
spautz Feb 13, 2020
c308e47
Cleanup
spautz Feb 13, 2020
d69b424
Cleanup after reviewing PR diff
spautz Feb 13, 2020
27eed7b
Remove onReady callback and create element at portal-creating time in…
spautz Feb 29, 2020
15760ee
Update imports, exports, and stories
spautz Feb 29, 2020
7dd15ed
Clean up and fix typings
spautz Feb 29, 2020
94ea29f
Use createHtmlPortalNode/createSvgPortalNode style of imports
spautz Mar 4, 2020
0dfaf2d
Update docs
spautz Mar 4, 2020
07db592
Organize and polish stories
spautz Mar 4, 2020
b39654f
Validate content sent into InPortal to ensure it matches elementType
spautz Mar 4, 2020
8c1fc37
Cleanup and dedupe code
spautz Mar 4, 2020
a5b9379
Remove elementType validation in InPortal, based on findings in https…
spautz Mar 4, 2020
6870bb5
Fix note in docs
spautz Mar 10, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Create a portal node, populate it with `InPortal`, and use it somewhere with `Ou
import * as portals from 'react-reverse-portal';

const MyComponent = (props) => {
const portalNode = React.useMemo(() => portals.createPortalNode(), []);
const portalNode = React.useMemo(() => portals.createHtmlPortalNode(), []);

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

How does it work under the hood?

### `portals.createPortalNode`
### `portals.createHtmlPortalNode`

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, 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.
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.

### `portals.createSvgPortalNode`

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

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

### `portals.InPortal`

Expand Down
104 changes: 82 additions & 22 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';

// Internally, the portalNode must be for either HTML or SVG elements
const ELEMENT_TYPE_HTML = 'html';
const ELEMENT_TYPE_SVG = 'svg';

type ANY_ELEMENT_TYPE = typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG;

// 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';

type Component<P> = React.Component<P> | React.ComponentType<P>;

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

export interface PortalNode<C extends Component<any> = Component<any>> extends HTMLElement {
interface PortalNodeBase<C extends Component<any>> {
// Used by the out portal to send props back to the real element
// Hooked by InPortal to become a state update (and thus rerender)
setPortalProps(p: ComponentProps<C>): void;
Expand All @@ -18,35 +28,70 @@ export interface PortalNode<C extends Component<any> = Component<any>> extends H
// latest placeholder we replaced. This avoids some race conditions.
unmount(expectedPlaceholder?: Node): void;
}

interface InPortalProps {
node: PortalNode;
children: React.ReactNode;
export interface HtmlPortalNode<C extends Component<any> = Component<any>> extends PortalNodeBase<C> {
element: HTMLElement;
elementType: typeof ELEMENT_TYPE_HTML;
}
export interface SvgPortalNode<C extends Component<any> = Component<any>> extends PortalNodeBase<C> {
element: SVGElement;
elementType: typeof ELEMENT_TYPE_SVG;
}
type AnyPortalNode<C extends Component<any> = Component<any>> = HtmlPortalNode<C> | SvgPortalNode<C>;


const validateElementType = (domElement: Element, elementType: ANY_ELEMENT_TYPE) => {
if (elementType === ELEMENT_TYPE_HTML) {
return domElement instanceof HTMLElement;
}
if (elementType === ELEMENT_TYPE_SVG) {
return domElement instanceof SVGElement;
}
throw new Error(`Unrecognized element type "${elementType}" for validateElementType.`);
};

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

let parent: Node | undefined;
let lastPlaceholder: Node | undefined;

const portalNode = Object.assign(document.createElement('div'), {
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".`);
}

const portalNode: AnyPortalNode<C> = {
element,
elementType,
setPortalProps: (props: ComponentProps<C>) => {
initialProps = props;
},
getInitialPortalProps: () => {
return initialProps;
},
mount: (newParent: Node, newPlaceholder: Node) => {
mount: (newParent: HTMLElement, newPlaceholder: HTMLElement) => {
if (newPlaceholder === lastPlaceholder) {
// Already mounted - noop.
return;
}
portalNode.unmount();

// To support SVG and other non-html elements, the portalNode's elementType needs to match
// the elementType it's being rendered into
if (newParent !== parent) {
if (!validateElementType(newParent, elementType)) {
throw new Error(`Invalid element type for portal: "${elementType}" portalNodes must be used with ${elementType} elements, but OutPortal is within <${newParent.tagName}>.`);
}
}

newParent.replaceChild(
portalNode,
newPlaceholder
portalNode.element,
newPlaceholder,
);

parent = newParent;
Expand All @@ -62,24 +107,29 @@ export const createPortalNode = <C extends Component<any>>(): PortalNode<C> => {
if (parent && lastPlaceholder) {
parent.replaceChild(
lastPlaceholder,
portalNode
portalNode.element,
);

parent = undefined;
lastPlaceholder = undefined;
}
}
});
} as AnyPortalNode<C>;

return portalNode;
};

export class InPortal extends React.PureComponent<InPortalProps, { nodeProps: {} }> {
interface InPortalProps {
node: AnyPortalNode;
children: React.ReactNode;
}

class InPortal extends React.PureComponent<InPortalProps, { nodeProps: {} }> {

constructor(props: InPortalProps) {
super(props);
this.state = {
nodeProps: this.props.node.getInitialPortalProps()
nodeProps: this.props.node.getInitialPortalProps(),
};
}

Expand Down Expand Up @@ -108,19 +158,19 @@ export class InPortal extends React.PureComponent<InPortalProps, { nodeProps: {}
if (!React.isValidElement(child)) return child;
return React.cloneElement(child, this.state.nodeProps)
}),
node
node.element
);
}
}

type OutPortalProps<C extends Component<any>> = {
node: PortalNode<C>
node: AnyPortalNode<C>
} & Partial<ComponentProps<C>>;

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

private placeholderNode = React.createRef<HTMLDivElement>();
private currentPortalNode?: PortalNode<C>;
private currentPortalNode?: AnyPortalNode<C>;

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

componentDidMount() {
const node = this.props.node as PortalNode<C>;
const node = this.props.node as AnyPortalNode<C>;
this.currentPortalNode = node;

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

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

componentWillUnmount() {
const node = this.props.node as PortalNode<C>;
const node = this.props.node as AnyPortalNode<C>;
node.unmount(this.placeholderNode.current!);
}

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

}
const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML) as () => HtmlPortalNode;
const createSvgPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_SVG) as () => SvgPortalNode;

export {
createHtmlPortalNode,
createSvgPortalNode,
InPortal,
OutPortal,
}
Loading