Skip to content

Instantly share code, notes, and snippets.

@mogelbrod
Last active May 22, 2020 10:23
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save mogelbrod/7b786f05297e55ea91169d2c9b99d55b to your computer and use it in GitHub Desktop.
Save mogelbrod/7b786f05297e55ea91169d2c9b99d55b to your computer and use it in GitHub Desktop.
Non-bubbling React Portals
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
}
@justjake
Copy link

Why isn't TouchEvent included in the event list?

@mogelbrod
Copy link
Author

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