@streetscape.gl/monochrome
Version:
A toolkit of React components for streetscape.gl
460 lines (393 loc) • 12.4 kB
JavaScript
// Copyright (c) 2019 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
/* global setTimeout, clearTimeout, document */
import React from 'react';
import PropTypes from 'prop-types';
import Popper from 'popper.js';
import styled from '@emotion/styled';
import {withTheme, evaluateStyle} from '../theme';
import {
capitalize,
getOppositePosition,
generateTriangleStyles,
nodeHasParent,
positionsToPopperPlacement
} from './utils';
const isBrowser = typeof document !== 'undefined' && Boolean(document.createElement);
export const POSITIONS = {
TOP: 'top',
RIGHT: 'right',
BOTTOM: 'bottom',
LEFT: 'left',
AUTO: 'auto'
};
export const TRIGGERS = {
CLICK: 'click',
HOVER: 'hover'
};
const DEFAULT_ARROW_SIZE = 6;
const DEFAULT_BORDER_WIDTH = 1;
const PopoverComponent = styled.div(props => ({
/* Default wrapper style should ideally be display: inline so that popover
shows directly beneath child content, inline-flex just generally works
better than inline */
display: 'inline-flex',
...evaluateStyle(props.userStyle, props)
}));
const PopoverTarget = styled.div(props => ({
...evaluateStyle(props.userStyle, props)
}));
const PopoverContent = styled.div(props => ({
...evaluateStyle(props.userStyle, props)
}));
const PopoverBody = styled.div(props => {
let style = null;
if (props.position && props.position !== POSITIONS.AUTO && props.arrowSize) {
style = {
[`margin${capitalize(getOppositePosition(props.position))}`]: props.arrowSize
};
}
return {
...props.theme.__reset__,
...style,
position: 'absolute',
top: 0,
left: 0,
zIndex: 99,
background: props.background || props.theme.background,
borderStyle: 'solid',
borderWidth: props.borderWidth,
borderColor: props.borderColor || props.theme.controlColorPrimary,
boxShadow: props.theme.shadow,
fontFamily: props.theme.fontFamily,
fontSize: props.theme.fontSize,
fontWeight: 'normal',
...props.popperStyles,
...evaluateStyle(props.userStyle, props)
};
});
const OuterArrow = styled.div(props => {
/**
* We create our arrow triangle using css borders, however this makes it
* difficult for us to add an actual border effect to the triangle. In order
* to achieve this effect, we generate two arrows and overlap them–an outer
* arrow with the border color, and an inner arrow with the background color.
*/
const {position, arrowSize, popperOffsets} = props;
if (!arrowSize) {
return null;
}
const arrowOffsets = popperOffsets.arrow || {};
const style = {
borderColor: props.borderColor || props.theme.controlColorPrimary,
// Position the arrow to hang off the edge of the popover
// For example, a left-facing arrow would need the rule right: -{size}px
[getOppositePosition(position)]: -arrowSize
};
// Now apply arrow offsets calculated by popper library (if needed)
if (arrowOffsets.top) {
style.top = arrowOffsets.top;
}
if (arrowOffsets.left) {
style.left = arrowOffsets.left;
}
return Object.assign(
style,
evaluateStyle(props.userStyle, props),
generateTriangleStyles(position, arrowSize)
);
});
const InnerArrow = styled.div(props => {
const {position, arrowSize} = props;
if (!arrowSize) {
return null;
}
const isVertical = position === POSITIONS.TOP || position === POSITIONS.BOTTOM;
const style = {
borderColor: props.background || props.theme.background
};
// Offset the inner arrow a couple pixels from its parent (the outer arrow)
if (isVertical) {
style.left = -arrowSize;
style.top = position === POSITIONS.TOP ? -arrowSize : 0;
style.marginTop = (position === POSITIONS.TOP ? -1 : 1) * props.borderWidth;
} else {
style.top = -arrowSize;
style.left = position === POSITIONS.LEFT ? -arrowSize : 0;
style.marginLeft = (position === POSITIONS.LEFT ? -1 : 1) * props.borderWidth;
}
return Object.assign(
style,
evaluateStyle(props.userStyle, props),
generateTriangleStyles(position, arrowSize)
);
});
const POSITIONS_PROP_TYPE = PropTypes.oneOf([
POSITIONS.TOP,
POSITIONS.RIGHT,
POSITIONS.BOTTOM,
POSITIONS.LEFT,
POSITIONS.AUTO
]);
class Popover extends React.Component {
static propTypes = {
className: PropTypes.string,
style: PropTypes.object,
content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
position: POSITIONS_PROP_TYPE,
// Arrow options
arrowPosition: POSITIONS_PROP_TYPE,
// Interaction
onMouseOutDelay: PropTypes.number,
trigger: PropTypes.oneOf([TRIGGERS.HOVER, TRIGGERS.CLICK]),
// Support injecting mock Popper class for tests
popperClass: PropTypes.func
};
static defaultProps = {
className: '',
style: {},
trigger: TRIGGERS.CLICK,
onMouseOutDelay: 0,
popperClass: Popper
};
constructor(props) {
super(props);
this.state = this._getDefaultState(props);
}
componentDidUpdate(prevProps, prevState) {
// If we just switched to showing popover, create a new Popper for positioning
if (this.state.isVisible && !prevState.isVisible) {
const PopperClass = this.props.popperClass;
this.popper = new PopperClass(this.$target, this.$popper, {
placement: this.state.popperPlacement,
modifiers: {
// Passing the arrow ref will measure the arrow when calculating styles
arrow: {
element: this.$arrow,
enabled: true
},
// Disable default styling modifier, and use our custom react one instead
applyStyle: {enabled: false},
applyReactStyle: {
enabled: true,
fn: this._handlePopperUpdate,
order: 900
}
}
});
if (this.props.trigger === TRIGGERS.CLICK) {
this._addClickEvents();
}
}
}
componentWillUnmount() {
this._destroyPopover();
this._removeClickEvents();
}
_getDefaultState(props) {
return {
isVisible: false,
popperOffsets: {},
popperPlacement: positionsToPopperPlacement(props.position, props.arrowPosition),
popperStyles: {}
};
}
_handleTargetClick = () => {
this._togglePopover();
};
_handleTargetMouseEnter = () => {
// If onMouseOutDelay is set and mouse is moving from popper back to target,
// we want to cancel any timers that would cause it to hide unexpectedly
clearTimeout(this.hideTimer);
this._showPopover();
};
_handleTargetMouseLeave = () => {
this._hidePopoverWithDelay();
};
_handlePopoverMouseEnter = () => {
clearTimeout(this.hideTimer);
};
_handlePopoverMouseLeave = () => {
this._hidePopoverWithDelay();
};
_handleKeyPress = evt => {
if (evt.key === 'Escape') {
this._hidePopover();
}
};
_handlePopperUpdate = data => {
this.setState({
popperOffsets: data.offsets,
popperPlacement: data.placement,
popperStyles: data.styles
});
return data;
};
_showPopover() {
this.setState({isVisible: true});
}
_hidePopover = () => {
this.setState(this._getDefaultState(this.props));
this._destroyPopover();
};
_hidePopoverWithDelay() {
const {onMouseOutDelay} = this.props;
if (onMouseOutDelay) {
this.hideTimer = setTimeout(this._hidePopover, onMouseOutDelay);
return;
}
this._hidePopover();
}
_addClickEvents() {
if (!isBrowser) {
return;
}
document.addEventListener('mousedown', this._handleDocumentClick);
document.addEventListener('keyup', this._handleKeyPress);
}
_removeClickEvents() {
if (!isBrowser) {
return;
}
document.removeEventListener('mousedown', this._handleDocumentClick);
document.removeEventListener('keyup', this._handleKeyPress);
}
_handleDocumentClick = evt => {
if (
!this.$popper ||
nodeHasParent(evt.target, this.$popper) ||
nodeHasParent(evt.target, this.$target)
) {
return;
}
this._hidePopover();
};
_togglePopover() {
const isVisible = !this.state.isVisible;
this.setState({isVisible});
if (!isVisible) {
this._destroyPopover();
}
}
_destroyPopover() {
if (this.popper) {
this.popper.destroy();
delete this.popper;
}
if (this.props.trigger === TRIGGERS.CLICK) {
this._removeClickEvents();
}
}
_renderTarget(styleProps) {
const {trigger, style} = this.props;
const interactionProps = {};
if (trigger === TRIGGERS.HOVER) {
interactionProps.onMouseEnter = this._handleTargetMouseEnter;
interactionProps.onMouseLeave = this._handleTargetMouseLeave;
} else if (trigger === TRIGGERS.CLICK) {
interactionProps.onClick = this._handleTargetClick;
}
return (
<PopoverTarget
{...interactionProps}
userStyle={style.target}
{...styleProps}
ref={el => {
this.$target = el;
}}
>
{this.props.children}
</PopoverTarget>
);
}
_renderArrow(styleProps) {
const {style} = this.props;
return (
<OuterArrow
userStyle={style.arrowBorder}
{...styleProps}
ref={el => {
this.$arrow = el;
}}
>
<InnerArrow userStyle={style.arrow} {...styleProps} />
</OuterArrow>
);
}
_renderBody(styleProps) {
const {className, style, trigger} = this.props;
const interactionProps = {};
if (trigger === TRIGGERS.HOVER) {
interactionProps.onMouseEnter = this._handlePopoverMouseEnter;
interactionProps.onMouseLeave = this._handlePopoverMouseLeave;
}
return (
<PopoverBody
className={className}
{...interactionProps}
userStyle={style.body}
{...styleProps}
ref={el => {
this.$popper = el;
}}
>
{this._renderContent(styleProps)}
{this._renderArrow(styleProps)}
</PopoverBody>
);
}
_renderContent(styleProps) {
const content =
typeof this.props.content === 'function' ? this.props.content() : this.props.content;
return (
<PopoverContent userStyle={this.props.style.content} {...styleProps}>
{content}
</PopoverContent>
);
}
render() {
const {theme, style} = this.props;
const {isVisible, popperPlacement, popperStyles, popperOffsets} = this.state;
const [position] = popperPlacement.split('-');
const arrowPosition = position === POSITIONS.AUTO ? POSITIONS.TOP : position;
const styleProps = {
theme,
arrowSize: 'arrowSize' in style ? style.arrowSize : DEFAULT_ARROW_SIZE,
background: style.background,
borderWidth: 'borderWidth' in style ? style.borderWidth : DEFAULT_BORDER_WIDTH,
borderColor: style.borderColor,
position,
arrowPosition,
popperStyles,
popperOffsets,
isActive: isVisible
};
return (
<PopoverComponent userStyle={style.popover} {...styleProps}>
{this._renderTarget(styleProps)}
{isVisible && this._renderBody(styleProps)}
</PopoverComponent>
);
}
}
const ThemedPopover = withTheme(Popover);
Object.assign(ThemedPopover, POSITIONS);
Object.assign(ThemedPopover, TRIGGERS);
export default ThemedPopover;