-
Notifications
You must be signed in to change notification settings - Fork 36
/
Copy pathindex.tsx
266 lines (221 loc) · 9.23 KB
/
index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
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;
type Options = {
attributes: { [key: string]: string };
};
// 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;
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;
// Used to track props set before the InPortal hooks setPortalProps
getInitialPortalProps(): ComponentProps<C>;
// Move the node from wherever it is, to this parent, replacing the placeholder
mount(newParent: Node, placeholder: Node): void;
// If mounted, unmount the node and put the initial placeholder back
// If an expected placeholder is provided, only unmount if that's still that was the
// latest placeholder we replaced. This avoids some race conditions.
unmount(expectedPlaceholder?: Node): void;
}
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.`);
};
// 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,
options?: Options
): AnyPortalNode<C> => {
let initialProps = {} as ComponentProps<C>;
let parent: Node | undefined;
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".`);
}
if (options && typeof options === "object") {
for (const [key, value] of Object.entries(options.attributes)) {
element.setAttribute(key, value);
}
}
const portalNode: AnyPortalNode<C> = {
element,
elementType,
setPortalProps: (props: ComponentProps<C>) => {
initialProps = props;
},
getInitialPortalProps: () => {
return initialProps;
},
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.element,
newPlaceholder,
);
parent = newParent;
lastPlaceholder = newPlaceholder;
},
unmount: (expectedPlaceholder?: Node) => {
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;
}
}
} as AnyPortalNode<C>;
return portalNode;
};
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(),
};
}
addPropsChannel = () => {
Object.assign(this.props.node, {
setPortalProps: (props: {}) => {
// Rerender the child node here if/when the out portal props change
this.setState({ nodeProps: props });
}
});
};
componentDidMount() {
this.addPropsChannel();
}
componentDidUpdate(previousProps: InPortalProps) {
this.addPropsChannel();
if(previousProps.node.element !== this.props.node.element){
Object.keys(window).forEach(key => {
if (/^on/.test(key)) {
const eventType = key.slice(2);
this.props.node.element.addEventListener(eventType, this.onEventHandler);
if(previousProps.node.element){
previousProps.node.element.removeEventListener(eventType, this.onEventHandler);
}
}
});
}
}
onEventHandler(e:any){
e.stopPropagation();
this.props.node.element.dispatchEvent(e);
}
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
);
}
}
type OutPortalProps<C extends Component<any>> = {
node: AnyPortalNode<C>
} & Partial<ComponentProps<C>>;
class OutPortal<C extends Component<any>> extends React.PureComponent<OutPortalProps<C>> {
private placeholderNode = React.createRef<HTMLDivElement>();
private currentPortalNode?: AnyPortalNode<C>;
constructor(props: OutPortalProps<C>) {
super(props);
this.passPropsThroughPortal();
}
passPropsThroughPortal() {
const propsForTarget = Object.assign({}, this.props, { node: undefined });
this.props.node.setPortalProps(propsForTarget);
}
componentDidMount() {
const node = this.props.node as AnyPortalNode<C>;
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 as AnyPortalNode<C>;
// 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 = node;
}
const placeholder = this.placeholderNode.current!;
const parent = placeholder.parentNode!;
node.mount(parent, placeholder);
this.passPropsThroughPortal();
}
componentWillUnmount() {
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
<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,
createSvgPortalNode,
InPortal,
OutPortal,
}