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 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ const portalNode = portals.createHtmlPortalNode({
});
```

The div's DOM node is also available at `.element`, so you can mutate that directly with the standard DOM APIs if preferred.
### `portals.createHtmlInlinePortalNode([options])`

Same as `portal.createHtmlPortalNode`, except it uses `<span>` instead of `<div>`. This can be helpful to avoid invalid HTML when portalling videos, etc into [phrasing content](https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#phrasing_content), which is invalid HTML markup and triggers [React validateDOMNesting](https://www.dhiwise.com/post/mastering-validatedomnesting-best-practices).

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

Expand Down
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> {
Copy link
Author

@aeharding aeharding Nov 18, 2024

Choose a reason for hiding this comment

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

In order to avoid breaking change, I merged block/inline elements into this interface.

Alternatively, I could separate into HtmlBlockPortalNode/HtmlInlinePortalNode (breaking change due to export name change), or keep HtmlPortalNode the same as it used to be, and export HtmlInlinePortalNode in addition/separately (non breaking change)

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
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"strict": true,
"rootDir": "./src",
"declaration": true,
"declarationDir": "./dist"
"declarationDir": "./dist",
"noFallthroughCasesInSwitch": true
},
"include": [
"./src/**/*.tsx"
Expand Down