semantic-ui-react
Version:
The official Semantic-UI-React integration.
385 lines (319 loc) • 10.9 kB
JavaScript
import EventStack from '@semantic-ui-react/event-stack'
import cx from 'clsx'
import _ from 'lodash'
import PropTypes from 'prop-types'
import React, { Component, createRef } from 'react'
import { Popper } from 'react-popper'
import shallowEqual from 'shallowequal'
import {
eventStack,
childrenUtils,
customPropTypes,
getElementType,
getUnhandledProps,
makeDebugger,
SUI,
useKeyOnly,
useKeyOrValueAndKey,
} from '../../lib'
import Portal from '../../addons/Portal'
import { placementMapping, positions, positionsMapping } from './lib/positions'
import createReferenceProxy from './lib/createReferenceProxy'
import PopupContent from './PopupContent'
import PopupHeader from './PopupHeader'
const debug = makeDebugger('popup')
/**
* A Popup displays additional information on top of a page.
*/
export default class Popup extends Component {
state = {}
open = false
triggerRef = createRef()
static getDerivedStateFromProps(props, state) {
if (state.closed || state.disabled) return {}
const unhandledProps = getUnhandledProps(Popup, props)
const contentRestProps = _.reduce(
unhandledProps,
(acc, val, key) => {
if (!_.includes(Portal.handledProps, key)) acc[key] = val
return acc
},
{},
)
const portalRestProps = _.pick(unhandledProps, Portal.handledProps)
return { contentRestProps, portalRestProps }
}
componentDidUpdate(prevProps) {
const depsEqual = shallowEqual(this.props.popperDependencies, prevProps.popperDependencies)
if (!depsEqual) {
this.handleUpdate()
}
}
componentWillUnmount() {
clearTimeout(this.timeoutId)
}
getPortalProps = () => {
debug('getPortalProps()')
const portalProps = {}
const { on, hoverable } = this.props
const normalizedOn = _.isArray(on) ? on : [on]
if (hoverable) {
portalProps.closeOnPortalMouseLeave = true
portalProps.mouseLeaveDelay = 300
}
if (_.includes(normalizedOn, 'hover')) {
portalProps.openOnTriggerClick = false
portalProps.closeOnTriggerClick = false
portalProps.openOnTriggerMouseEnter = true
portalProps.closeOnTriggerMouseLeave = true
// Taken from SUI: https://git.io/vPmCm
portalProps.mouseLeaveDelay = 70
portalProps.mouseEnterDelay = 50
}
if (_.includes(normalizedOn, 'click')) {
portalProps.openOnTriggerClick = true
portalProps.closeOnTriggerClick = true
portalProps.closeOnDocumentClick = true
}
if (_.includes(normalizedOn, 'focus')) {
portalProps.openOnTriggerFocus = true
portalProps.closeOnTriggerBlur = true
}
return portalProps
}
hideOnScroll = (e) => {
debug('hideOnScroll()')
this.setState({ closed: true })
eventStack.unsub('scroll', this.hideOnScroll, { target: window })
this.timeoutId = setTimeout(() => {
this.setState({ closed: false })
}, 50)
this.handleClose(e)
}
handleClose = (e) => {
debug('handleClose()')
_.invoke(this.props, 'onClose', e, { ...this.props, open: false })
}
handleOpen = (e) => {
debug('handleOpen()')
_.invoke(this.props, 'onOpen', e, { ...this.props, open: true })
}
handlePortalMount = (e) => {
debug('handlePortalMount()')
_.invoke(this.props, 'onMount', e, this.props)
}
handlePortalUnmount = (e) => {
debug('handlePortalUnmount()')
this.positionUpdate = null
_.invoke(this.props, 'onUnmount', e, this.props)
}
handleUpdate() {
if (this.positionUpdate) this.positionUpdate()
}
renderContent = ({ placement: popperPlacement, ref: popperRef, update, style: popperStyle }) => {
const {
basic,
children,
className,
content,
hideOnScroll,
flowing,
header,
inverted,
size,
style,
wide,
} = this.props
const { contentRestProps } = this.state
this.positionUpdate = update
const classes = cx(
'ui',
placementMapping[popperPlacement],
size,
useKeyOrValueAndKey(wide, 'wide'),
useKeyOnly(basic, 'basic'),
useKeyOnly(flowing, 'flowing'),
useKeyOnly(inverted, 'inverted'),
'popup transition visible',
className,
)
const ElementType = getElementType(Popup, this.props)
const styles = {
// Heads up! We need default styles to get working correctly `flowing`
left: 'auto',
right: 'auto',
// This is required to be properly positioned inside wrapping `div`
position: 'initial',
...style,
}
return (
// https://github.com/popperjs/popper-core/blob/f1f9d1ab75b6b0e962f90a5b2a50f6cfd307d794/src/createPopper.js#L136-L137
// Heads up!
// A wrapping `div` there is a pure magic, it's required as Popper warns on margins that are
// defined by SUI CSS. It also means that this `div` will be positioned instead of `content`.
<div ref={popperRef} style={popperStyle}>
<ElementType {...contentRestProps} className={classes} style={styles}>
{childrenUtils.isNil(children) ? (
<>
{PopupHeader.create(header, { autoGenerateKey: false })}
{PopupContent.create(content, { autoGenerateKey: false })}
</>
) : (
children
)}
{hideOnScroll && <EventStack on={this.hideOnScroll} name='scroll' target='window' />}
</ElementType>
</div>
)
}
render() {
const {
context,
disabled,
eventsEnabled,
offset,
pinned,
popperModifiers,
position,
positionFixed,
trigger,
} = this.props
const { closed, portalRestProps } = this.state
if (closed || disabled) {
return trigger
}
const modifiers = [
{ name: 'arrow', enabled: false },
{ name: 'eventListeners', options: { scroll: !!eventsEnabled, resize: !!eventsEnabled } },
{ name: 'flip', enabled: !pinned },
{ name: 'preventOverflow', enabled: !!offset },
{ name: 'offset', enabled: !!offset, options: { offset } },
...popperModifiers,
]
debug('popper modifiers:', modifiers)
const referenceElement = createReferenceProxy(_.isNil(context) ? this.triggerRef : context)
const mergedPortalProps = { ...this.getPortalProps(), ...portalRestProps }
debug('portal props:', mergedPortalProps)
return (
<Portal
{...mergedPortalProps}
onClose={this.handleClose}
onMount={this.handlePortalMount}
onOpen={this.handleOpen}
onUnmount={this.handlePortalUnmount}
trigger={trigger}
triggerRef={this.triggerRef}
>
<Popper
modifiers={modifiers}
placement={positionsMapping[position]}
strategy={positionFixed ? 'fixed' : null}
referenceElement={referenceElement}
>
{this.renderContent}
</Popper>
</Portal>
)
}
}
Popup.propTypes = {
/** An element type to render as (string or function). */
as: PropTypes.elementType,
/** Display the popup without the pointing arrow. */
basic: PropTypes.bool,
/** Primary content. */
children: PropTypes.node,
/** Additional classes. */
className: PropTypes.string,
/** Simple text content for the popover. */
content: customPropTypes.itemShorthand,
/** Existing element the pop-up should be bound to. */
context: PropTypes.oneOfType([PropTypes.object, customPropTypes.refObject]),
/** A disabled popup only renders its trigger. */
disabled: PropTypes.bool,
/** Enables the Popper.js event listeners. */
eventsEnabled: PropTypes.bool,
/** A flowing Popup has no maximum width and continues to flow to fit its content. */
flowing: PropTypes.bool,
/** Takes up the entire width of its offset container. */
// TODO: implement the Popup fluid layout
// fluid: PropTypes.bool,
/** Header displayed above the content in bold. */
header: customPropTypes.itemShorthand,
/** Hide the Popup when scrolling the window. */
hideOnScroll: PropTypes.bool,
/** Whether the popup should not close on hover. */
hoverable: PropTypes.bool,
/** Invert the colors of the Popup. */
inverted: PropTypes.bool,
/**
* Offset values in px unit to apply to rendered popup. The basic offset accepts an
* array with two numbers in the form [skidding, distance]:
* - `skidding` displaces the Popup along the reference element
* - `distance` displaces the Popup away from, or toward, the reference element in the direction of its placement. A positive number displaces it further away, while a negative number lets it overlap the reference.
*
* @see https://popper.js.org/docs/v2/modifiers/offset/
*/
offset: PropTypes.oneOfType([PropTypes.func, PropTypes.arrayOf(PropTypes.number)]),
/** Events triggering the popup. */
on: PropTypes.oneOfType([
PropTypes.oneOf(['hover', 'click', 'focus']),
PropTypes.arrayOf(PropTypes.oneOf(['hover', 'click', 'focus'])),
]),
/**
* Called when a close event happens.
*
* @param {SyntheticEvent} event - React's original SyntheticEvent.
* @param {object} data - All props.
*/
onClose: PropTypes.func,
/**
* Called when the portal is mounted on the DOM.
*
* @param {null}
* @param {object} data - All props.
*/
onMount: PropTypes.func,
/**
* Called when an open event happens.
*
* @param {SyntheticEvent} event - React's original SyntheticEvent.
* @param {object} data - All props.
*/
onOpen: PropTypes.func,
/**
* Called when the portal is unmounted from the DOM.
*
* @param {null}
* @param {object} data - All props.
*/
onUnmount: PropTypes.func,
/** Disables automatic repositioning of the component, it will always be placed according to the position value. */
pinned: PropTypes.bool,
/** Position for the popover. */
position: PropTypes.oneOf(positions),
/** Tells `Popper.js` to use the `position: fixed` strategy to position the popover. */
positionFixed: PropTypes.bool,
/** An array containing custom settings for the Popper.js modifiers. */
popperModifiers: PropTypes.array,
/** A popup can have dependencies which update will schedule a position update. */
popperDependencies: PropTypes.array,
/** Popup size. */
size: PropTypes.oneOf(_.without(SUI.SIZES, 'medium', 'big', 'massive')),
/** Custom Popup style. */
style: PropTypes.object,
/** Element to be rendered in-place where the popup is defined. */
trigger: PropTypes.node,
/** Popup width. */
wide: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf(['very'])]),
}
Popup.defaultProps = {
disabled: false,
eventsEnabled: true,
on: ['click', 'hover'],
pinned: false,
popperModifiers: [],
position: 'top left',
}
Popup.Content = PopupContent
Popup.Header = PopupHeader