Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add inline portal node support #45

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
52 changes: 33 additions & 19 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ 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_HTML_BLOCK = 'div';
const ELEMENT_TYPE_HTML_INLINE = 'span';
const ELEMENT_TYPE_SVG = 'svg';

type ANY_ELEMENT_TYPE = typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG;
type ANY_ELEMENT_TYPE = typeof ELEMENT_TYPE_HTML_BLOCK | typeof ELEMENT_TYPE_HTML_INLINE | typeof ELEMENT_TYPE_SVG;

type Options = {
attributes: { [key: string]: string };
Expand Down Expand Up @@ -34,7 +35,7 @@ interface PortalNodeBase<C extends Component<any>> {
}
export interface HtmlPortalNode<C extends Component<any> = Component<any>> extends PortalNodeBase<C> {
element: HTMLElement;
elementType: typeof ELEMENT_TYPE_HTML;
elementType: typeof ELEMENT_TYPE_HTML_BLOCK | typeof ELEMENT_TYPE_HTML_INLINE;
}
export interface SvgPortalNode<C extends Component<any> = Component<any>> extends PortalNodeBase<C> {
element: SVGElement;
Expand All @@ -48,13 +49,16 @@ const validateElementType = (domElement: Element, elementType: ANY_ELEMENT_TYPE)
// 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_BLOCK:
case ELEMENT_TYPE_HTML_INLINE:
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
Expand All @@ -68,12 +72,17 @@ const createPortalNode = <C extends Component<any>>(
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_BLOCK:
case ELEMENT_TYPE_HTML_INLINE:
element = document.createElement(elementType);
break;
case ELEMENT_TYPE_SVG:
element = document.createElementNS(SVG_NAMESPACE, 'g');
break;
default:
throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "div", "span" or "svg".`);
}

if (options && typeof options === "object") {
Expand Down Expand Up @@ -186,7 +195,7 @@ type OutPortalProps<C extends Component<any>> = {

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

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

constructor(props: OutPortalProps<C>) {
Expand Down Expand Up @@ -236,18 +245,23 @@ class OutPortal<C extends Component<any>> extends React.PureComponent<OutPortalP
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} />;
// A <span> placeholder:
// - prevents invalid HTML (e.g. inside <p>)
// - works fine even for SVG.
return <span ref={this.placeholderNode} />;
}
}

const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML) as
const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML_BLOCK) as
<C extends Component<any> = Component<any>>(options?: Options) => HtmlPortalNode<C>;
const createHtmlInlinePortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML_INLINE) as
<C extends Component<any> = Component<any>>(options?: Options) => HtmlPortalNode<C>;
const createSvgPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_SVG) as
<C extends Component<any> = Component<any>>(options?: Options) => SvgPortalNode<C>;

export {
createHtmlPortalNode,
createHtmlInlinePortalNode,
createSvgPortalNode,
InPortal,
OutPortal,
Expand Down
19 changes: 18 additions & 1 deletion stories/html.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';

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

import { createHtmlPortalNode, createSvgPortalNode, InPortal, OutPortal } from '..';
import { createHtmlPortalNode, createHtmlInlinePortalNode, InPortal, OutPortal } from '..';
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left createHtmlPortalNode the same to avoid breaking change (e.g. to createHtmlBlockPortalNode)

I think most people will want the block version anyways.


const Container = (props) =>
<div style={{ "border": "1px solid #222", "padding": "10px" }}>
Expand Down Expand Up @@ -289,6 +289,23 @@ storiesOf('Portals', module)
</div>
});
})
.add('can render inline portal', () => {
const portalNode = createHtmlInlinePortalNode();

return <div>
<p>
Portal defined here:
<InPortal node={portalNode}>
Hi!
</InPortal>
</p>

<p>
Portal renders here:
<OutPortal node={portalNode} />
</p>
</div>;
})
.add('Example from README', () => {
const MyExpensiveComponent = () => 'expensive!';

Expand Down