Last active
May 22, 2020 10:23
-
-
Save mogelbrod/7b786f05297e55ea91169d2c9b99d55b to your computer and use it in GitHub Desktop.
Non-bubbling React Portals
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import PropTypes from 'prop-types' | |
import React from 'react' | |
import ReactDOM from 'react-dom' | |
function portalContainer() { | |
return document.getElementById('portal-container') | |
} | |
// Taken from https://reactjs.org/docs/events.html | |
const DEFAULT_EVENT_LISTENERS = [ | |
// Clipboard | |
'onCopy', 'onCut', 'onPaste', | |
// Composition - seldom used? | |
// 'onCompositionEnd', 'onCompositionStart', 'onCompositionUpdate', | |
// Keyboard | |
'onKeyDown', 'onKeyPress', 'onKeyUp', | |
// Focus - doesn't bubble, blocking them will prevent native listeners from working | |
// 'onFocus', 'onBlur', | |
// Form | |
'onChange', 'onInput', 'onInvalid', 'onSubmit', | |
// Mouse | |
'onClick', 'onContextMenu', 'onDoubleClick', | |
'onMouseDown', 'onMouseUp', | |
'onMouseEnter', 'onMouseLeave', | |
'onMouseMove', 'onMouseOut', 'onMouseOver', | |
// Drag & drop | |
'onDrag', 'onDragEnd', 'onDragEnter', 'onDragExit', | |
'onDragLeave', 'onDragOver', 'onDragStart', 'onDrop', | |
// Pointer | |
'onPointerDown', 'onPointerMove', 'onPointerUp', 'onPointerCancel', | |
'onPointerEnter', 'onPointerLeave', 'onPointerOver', 'onPointerOut', | |
// Selection | |
'onSelect', | |
// Touch | |
'onTouchCancel', 'onTouchEnd', 'onTouchMove', 'onTouchStart', | |
// UI | |
'onScroll', | |
// Wheel | |
'onWheel', | |
// Media | |
'onAbort', 'onCanPlay', 'onCanPlayThrough', 'onDurationChange', 'onEmptied', | |
'onEncrypted', 'onEnded', 'onError', 'onLoadedData', 'onLoadedMetadata', | |
'onLoadStart', 'onPause', 'onPlay', 'onPlaying', 'onProgress', | |
'onRateChange', 'onSeeked', 'onSeeking', 'onStalled', 'onSuspend', | |
'onTimeUpdate', 'onVolumeChange', 'onWaiting', | |
// Image | |
'onLoad', 'onError', | |
// Animation | |
'onAnimationStart', 'onAnimationEnd', 'onAnimationIteration', | |
// Transition | |
'onTransitionEnd', | |
// Other | |
'onToggle', | |
] | |
/** | |
* Portal which by default doesn't bubble events triggered within it to React | |
* parent components. | |
* | |
* Adds blocking event handlers for most SyntheticEvents until | |
* native support for event bubbling prevention is added. | |
* | |
* See https://github.com/facebook/react/issues/11387 | |
*/ | |
export default class Portal extends React.Component { | |
static propTypes = { | |
/** List of event names (such as `onClick`) to avoid bubbling up React component hierarchy */ | |
blockedEvents: PropTypes.arrayOf(PropTypes.string).isRequired, | |
/** Custom DOM node to render portal inside */ | |
container: PropTypes.object, | |
} | |
static defaultProps = { | |
blockedEvents: DEFAULT_EVENT_LISTENERS, | |
} | |
static DEFAULT_EVENT_LISTENERS = DEFAULT_EVENT_LISTENERS | |
render() { | |
const { blockedEvents, container, ...props } = this.props | |
for (let eventName of blockedEvents) { | |
props[eventName] = stopAndRedispatchEventOnWindow | |
} | |
return ReactDOM.createPortal( | |
React.createElement('div', props, this.props.children), | |
container || portalContainer() | |
) | |
} | |
} | |
const EVENT_TYPE_WHITELIST = { | |
CustomEvent: true, | |
Event: true, | |
FocusEvent: true, | |
KeyboardEvent: true, | |
MouseEvent: true, | |
PointerEvent: true, | |
TouchEvent: true, | |
WheelEvent: true, | |
} | |
const EVENT_PROP_OVERRIDES = { | |
eventPhase: Event.BUBBLING_PHASE, | |
currentTarget: window, | |
} | |
/** | |
* Stops the propagation of a React event within its component hierarchy while | |
* also dispatching a clone of it to `window`. | |
* | |
* Event types not present in EVENT_TYPE_WHITELIST can't be cloned, and will | |
* instead be allowed through. | |
* | |
* @param {React.SyntheticEvent} event - React event | |
* @return {Event|null} Cloned native event dispatched to `window` | |
*/ | |
/* export */ function stopAndRedispatchEventOnWindow(event) { | |
const { nativeEvent } = event | |
const eventType = nativeEvent.constructor.name || | |
{}.toString.call(nativeEvent.constructor).slice(8, -1) | |
if (!EVENT_TYPE_WHITELIST[eventType]) { | |
// console.warn(`Ignored ${eventType}(${event.type}) event`) | |
return null | |
} | |
event.stopPropagation() | |
// Clone original native event | |
let windowEvent | |
try { | |
windowEvent = new (nativeEvent.constructor)(nativeEvent.type, nativeEvent) | |
} catch (error) { | |
// Legacy initialization of events for IE | |
// FIXME: IE prevents the read-only property `target` from being set via | |
// `defineProperty`. This makes it seemingly impossible to clone events in IE11. | |
console.error(`Unable to clone ${eventType}(${event.type})`, error) | |
} | |
for (let prop in nativeEvent) { | |
if (prop !== 'isTrusted' && typeof nativeEvent[prop] !== 'function') { | |
Object.defineProperty(windowEvent, prop, { | |
writable: false, | |
value: EVENT_PROP_OVERRIDES[prop] || nativeEvent[prop] | |
}) | |
} | |
} | |
// TODO: Should this be scheduled differently? | |
requestAnimationFrame(() => { | |
window.dispatchEvent(windowEvent) | |
}) | |
return windowEvent | |
} |
Why isn't TouchEvent included in the event list?
That would be an oversight by me, as the event handlers are included. I've updated the gist to include it.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Why isn't TouchEvent included in the event list?