react-reverse-portal
Version:
Build an element once, move it anywhere
178 lines • 8.08 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.OutPortal = exports.InPortal = exports.createSvgPortalNode = exports.createHtmlPortalNode = void 0;
const React = require("react");
const ReactDOM = require("react-dom");
// Internally, the portalNode must be for either HTML or SVG elements
const ELEMENT_TYPE_HTML = 'html';
const ELEMENT_TYPE_SVG = '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';
const validateElementType = (domElement, elementType) => {
var _a, _b, _c;
const ownerDocument = ((_a = domElement.ownerDocument) !== null && _a !== void 0 ? _a : document);
// 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 = (_c = (_b = ownerDocument.defaultView) !== null && _b !== void 0 ? _b : ownerDocument.parentWindow) !== null && _c !== void 0 ? _c : window; // `parentWindow` for IE8 and earlier
switch (elementType) {
case ELEMENT_TYPE_HTML:
return domElement instanceof ownerWindow.HTMLElement;
case ELEMENT_TYPE_SVG:
return domElement instanceof ownerWindow.SVGElement;
default:
throw new Error(`Unrecognized element type "${elementType}" for validateElementType.`);
}
};
// This is the internal implementation: the public entry points set elementType to an appropriate value
const createPortalNode = (elementType, options) => {
var _a, _b;
let initialProps = {};
let parent;
let lastPlaceholder;
let element;
switch (elementType) {
case ELEMENT_TYPE_HTML:
element = document.createElement((_a = options === null || options === void 0 ? void 0 : options.containerElement) !== null && _a !== void 0 ? _a : 'div');
break;
case ELEMENT_TYPE_SVG:
element = document.createElementNS(SVG_NAMESPACE, (_b = options === null || options === void 0 ? void 0 : options.containerElement) !== null && _b !== void 0 ? _b : 'g');
break;
default:
throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "html" or "svg".`);
}
if (options && typeof options === "object" && options.attributes) {
for (const [key, value] of Object.entries(options.attributes)) {
element.setAttribute(key, value);
}
}
const portalNode = {
element,
elementType,
setPortalProps: (props) => {
initialProps = props;
},
getInitialPortalProps: () => {
return initialProps;
},
mount: (newParent, newPlaceholder) => {
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.element, newPlaceholder);
parent = newParent;
lastPlaceholder = newPlaceholder;
},
unmount: (expectedPlaceholder) => {
if (expectedPlaceholder && expectedPlaceholder !== lastPlaceholder) {
// Skip unmounts for placeholders that aren't currently mounted
// They will have been automatically unmounted already by a subsequent mount()
return;
}
if (parent && lastPlaceholder) {
parent.replaceChild(lastPlaceholder, portalNode.element);
parent = undefined;
lastPlaceholder = undefined;
}
}
};
return portalNode;
};
class InPortal extends React.PureComponent {
constructor(props) {
super(props);
this.addPropsChannel = () => {
Object.assign(this.props.node, {
setPortalProps: (props) => {
// Rerender the child node here if/when the out portal props change
this.setState({ nodeProps: props });
}
});
};
this.state = {
nodeProps: this.props.node.getInitialPortalProps(),
};
}
componentDidMount() {
this.addPropsChannel();
}
componentDidUpdate() {
this.addPropsChannel();
}
render() {
const { children, node } = this.props;
return ReactDOM.createPortal(React.Children.map(children, (child) => {
if (!React.isValidElement(child))
return child;
return React.cloneElement(child, this.state.nodeProps);
}), node.element);
}
}
exports.InPortal = InPortal;
class OutPortal extends React.PureComponent {
constructor(props) {
super(props);
this.placeholderNode = React.createRef();
this.passPropsThroughPortal();
}
passPropsThroughPortal() {
const propsForTarget = Object.assign({}, this.props, { node: undefined });
this.props.node.setPortalProps(propsForTarget);
}
componentDidMount() {
const node = this.props.node;
this.currentPortalNode = node;
const placeholder = this.placeholderNode.current;
const parent = placeholder.parentNode;
node.mount(parent, placeholder);
this.passPropsThroughPortal();
}
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;
// If we're switching portal nodes, we need to clean up the current one first.
if (this.currentPortalNode && node !== this.currentPortalNode) {
this.currentPortalNode.unmount(this.placeholderNode.current);
this.currentPortalNode.setPortalProps({});
this.currentPortalNode = node;
}
const placeholder = this.placeholderNode.current;
const parent = placeholder.parentNode;
node.mount(parent, placeholder);
this.passPropsThroughPortal();
}
componentWillUnmount() {
const node = this.props.node;
node.unmount(this.placeholderNode.current);
node.setPortalProps({});
}
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.
const tagName = this.props.node.element.tagName;
// SVG tagName is lowercase and case sensitive, HTML is uppercase and case insensitive.
// React.createElement expects lowercase first letter to treat as non-component element.
// (Passing uppercase type won't break anything, but React warns otherwise:)
// https://github.com/facebook/react/blob/8039f1b2a05d00437cd29707761aeae098c80adc/CHANGELOG.md?plain=1#L1984
const type = this.props.node.elementType === ELEMENT_TYPE_HTML
? tagName.toLowerCase()
: tagName;
return React.createElement(type, { ref: this.placeholderNode });
}
}
exports.OutPortal = OutPortal;
const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML);
exports.createHtmlPortalNode = createHtmlPortalNode;
const createSvgPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_SVG);
exports.createSvgPortalNode = createSvgPortalNode;
//# sourceMappingURL=index.js.map