UNPKG

@streetscape.gl/monochrome

Version:

A toolkit of React components for streetscape.gl

460 lines (393 loc) 12.4 kB
// 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;