UNPKG

lucid-ui

Version:

A UI component library from AppNexus.

350 lines (349 loc) 14.4 kB
/* eslint-disable react/prop-types */ import React from 'react'; import PropTypes from 'react-peek/prop-types'; import _ from 'lodash'; import Portal from '../Portal/Portal'; import { getFirst, omitProps } from '../../util/component-types'; import { getAbsoluteBoundingClientRect, sharesAncestor, } from '../../util/dom-helpers'; import { lucidClassNames, uniqueName } from '../../util/style-helpers'; const cx = lucidClassNames.bind('&-ContextMenu'); const { bool, node, func, number, object, oneOf, string } = PropTypes; const ContextMenuTarget = (_props) => null; ContextMenuTarget.displayName = 'ContextMenu.Target'; ContextMenuTarget.propName = 'Target'; ContextMenuTarget.peek = { description: `Renders an element of \`elementType\` (defaults to \`<span>\`) that the menu \`FlyOut\` anchors to.`, }; ContextMenuTarget.propTypes = { elementType: string, }; ContextMenuTarget.defaultProps = { elementType: 'span', }; const ContextMenuFlyOut = (_props) => null; ContextMenuFlyOut.displayName = 'ContextMenu.FlyOut'; ContextMenuFlyOut.propName = 'FlyOut'; ContextMenuFlyOut.peek = { description: `Renders a \`<Portal>\` anchored to the \`Target\`.`, }; /** These have to be lowercase because: * 1. the key and value have to match * (limitation of TypeScript, see: https://github.com/Microsoft/TypeScript/issues/17198) * 2. the values are currently lowercase in the propTypes * */ export var EnumDirection; (function (EnumDirection) { EnumDirection["up"] = "up"; EnumDirection["down"] = "down"; EnumDirection["left"] = "left"; EnumDirection["right"] = "right"; })(EnumDirection || (EnumDirection = {})); export var EnumAlignment; (function (EnumAlignment) { EnumAlignment["start"] = "start"; EnumAlignment["center"] = "center"; EnumAlignment["end"] = "end"; })(EnumAlignment || (EnumAlignment = {})); /** default styling hides portal because its position can't be calculated * properly until after 1st render so here we unhide it if the ref exists */ const defaultFlyoutPosition = { opacity: 1, maxHeight: 'none', left: 'auto', top: 'auto', }; class ContextMenu extends React.Component { constructor() { super(...arguments); this.targetRef = React.createRef(); this.flyOutPortalRef = React.createRef(); this.state = { portalId: this.props.portalId || uniqueName('ContextMenu-Portal-'), targetRect: { bottom: 0, top: 0, left: 0, right: 0, height: 0, width: 0, }, flyOutHeight: 0, flyOutWidth: 0, }; // TODO: does this need to be instance property? this.continueAlignment = false; this.beginAlignment = () => { this.continueAlignment = true; window.requestAnimationFrame(this.handleAlignment); }; this.endAlignment = () => { this.continueAlignment = false; }; this.handleAlignment = () => { if (this.continueAlignment) { if (this.props.isExpanded) { this.alignFlyOut(true); } window.requestAnimationFrame(this.handleAlignment); } }; this.handleBodyClick = (event) => { const { props, props: { onClickOut }, flyOutPortalRef, targetRef, } = this; // in this block, I assert the type of target because EventTarget -> Element -> HtmlElement (from general to specific typing) const eventTarget = event.target; if (onClickOut && flyOutPortalRef.current && targetRef.current && eventTarget && eventTarget.nodeName) { const flyOutEl = flyOutPortalRef.current.portalElement.firstChild; const wasALabelClick = eventTarget.nodeName === 'INPUT' && sharesAncestor(eventTarget, targetRef.current, 'LABEL'); // Attempt to detect <label> click and ignore it if (wasALabelClick) { return; } if (!(flyOutEl.contains(eventTarget) || targetRef.current.contains(eventTarget)) && event.type === 'click') { onClickOut({ props, event: event }); } } }; this.calcAlignmentOffset = ({ direction, alignment, getAlignmentOffset, flyOutHeight, flyOutWidth, }) => { const { up: UP, down: DOWN } = EnumDirection; const { center: CENTER } = EnumAlignment; return !_.isUndefined(this.props.alignmentOffset) ? this.props.alignmentOffset : alignment === CENTER ? getAlignmentOffset(_.includes([UP, DOWN], direction) ? flyOutWidth : flyOutHeight) : 0; }; this.getMatch = ({ direction, alignment, flyOutHeight, flyOutWidth, clientWidth, directonOffset, alignmentOffset, top, bottom, left, right, width, height, }) => { const { up: UP, down: DOWN, left: LEFT, right: RIGHT } = EnumDirection; const { start: START, center: CENTER, end: END } = EnumAlignment; const options = { [UP]: { [START]: { top: top - flyOutHeight - directonOffset, left: left - alignmentOffset, }, [CENTER]: { top: top - flyOutHeight - directonOffset, left: left + width / 2 - flyOutWidth / 2 + alignmentOffset, }, [END]: { top: top - flyOutHeight - directonOffset, right: clientWidth - right - alignmentOffset, }, }, [DOWN]: { [START]: { top: bottom + directonOffset, left: left - alignmentOffset, }, [CENTER]: { top: bottom + directonOffset, left: left + width / 2 - flyOutWidth / 2 + alignmentOffset, }, [END]: { top: bottom + directonOffset, right: clientWidth - right - alignmentOffset, }, }, [LEFT]: { [START]: { top: top - alignmentOffset, right: clientWidth - left + directonOffset, }, [CENTER]: { top: top - flyOutHeight / 2 + height / 2 + alignmentOffset, right: clientWidth - left + directonOffset, }, [END]: { top: top - flyOutHeight + height + alignmentOffset, right: clientWidth - left + directonOffset, }, }, [RIGHT]: { [START]: { top: top - alignmentOffset, left: left + width + directonOffset, }, [CENTER]: { top: top - flyOutHeight / 2 + height / 2 + alignmentOffset, left: left + width + directonOffset, }, [END]: { top: top - flyOutHeight + height + alignmentOffset, left: left + width + directonOffset, }, }, }; return { ...defaultFlyoutPosition, ...options[direction][alignment], }; }; this.getFlyoutPosition = () => { const { props: { direction, alignment, directonOffset = ContextMenu.defaultProps.directonOffset, getAlignmentOffset = ContextMenu.defaultProps.getAlignmentOffset, }, state: { flyOutHeight, flyOutWidth, targetRect: { bottom, left, right, top, width, height }, }, flyOutPortalRef, } = this; const { clientWidth } = document.body; if (!flyOutPortalRef.current) return {}; if (direction && alignment) { return this.getMatch({ direction, alignment, flyOutHeight, flyOutWidth, clientWidth, directonOffset, alignmentOffset: this.calcAlignmentOffset({ direction, alignment, getAlignmentOffset, flyOutHeight, flyOutWidth, }), top, bottom, left, right, width, height, }); } }; this.alignFlyOut = (doRedunancyCheck = false) => { const { flyOutPortalRef, targetRef } = this; if (!targetRef.current || !flyOutPortalRef.current) { return; } const targetRect = getAbsoluteBoundingClientRect(targetRef.current); const portalRef = flyOutPortalRef.current; // Don't cause a state-change if target dimensions are the same if (doRedunancyCheck && targetRect.left === this.state.targetRect.left && targetRect.top === this.state.targetRect.top && targetRect.height === this.state.targetRect.height && targetRect.width === this.state.targetRect.width) { return; } if (portalRef) { const flyOutEl = portalRef.portalElement.firstChild; const { height, width, } = flyOutEl.getBoundingClientRect(); this.setState({ targetRect, flyOutHeight: height, flyOutWidth: width, }); } }; } UNSAFE_componentWillReceiveProps() { _.defer(() => this.alignFlyOut()); } componentDidMount() { _.defer(() => this.alignFlyOut()); this.beginAlignment(); document.body.addEventListener('touchstart', this.handleBodyClick); document.body.addEventListener('click', this.handleBodyClick); } componentWillUnmount() { this.endAlignment(); document.body.removeEventListener('click', this.handleBodyClick); } render() { const { props: { className, direction, isExpanded, style, minWidthOffset, ...passThroughs }, state: { portalId, targetRect }, } = this; const targetElement = getFirst(this.props, ContextMenu.Target); const targetChildren = _.get(targetElement, 'props.children', null); const TargetElementType = targetElement.props.elementType; const flyoutElement = getFirst(this.props, ContextMenu.FlyOut); const flyProps = _.get(flyoutElement, 'props', {}); return (React.createElement(TargetElementType, Object.assign({ ref: this.targetRef }, omitProps(passThroughs, undefined, _.keys(ContextMenu.propTypes)), { className: cx('&', className), style: style }), targetChildren, isExpanded ? (React.createElement(Portal, Object.assign({ ref: this.flyOutPortalRef }, flyProps, { className: cx('&-FlyOut', `&-FlyOut-${direction}`, flyProps.className), portalId: portalId, style: { minWidth: targetRect.width + minWidthOffset, ...this.getFlyoutPosition(), ...flyProps.style, } }), flyProps.children)) : null)); } } ContextMenu.displayName = 'ContextMenu'; ContextMenu.peek = { description: ` A ContextMenu component is used to render a target and a flyout which is positioned relative to the target. `, categories: ['utility'], madeFrom: ['Portal'], }; ContextMenu.propTypes = { children: node ` \`children\` should include exactly one ContextMenu.Target and one ContextMenu.FlyOut. `, className: string ` Appended to the component-specific class names set on the root element. `, style: object ` Passed through to the root element. `, direction: oneOf(['down', 'up', 'right', 'left']) ` direction of the FlyOut relative to Target. `, directonOffset: number ` the px offset along the axis of the direction `, alignment: oneOf(['start', 'center', 'end']) ` alignment of the Flyout relative to Target in the cross axis from \`direction\`. `, alignmentOffset: number ` the px offset along the axis of the alignment `, getAlignmentOffset: func ` an alternative to \`alignmentOffset\`, a function that is applied with the width/height of the flyout. the result is used as the \`alignmentOffset\` `, minWidthOffset: number ` The number of px's to grow or shrink the minWidth of the FlyOut `, isExpanded: bool ` Indicates whether the FlyOut will render or not. `, onClickOut: func ` Called when a click event happenens outside of the ContextMenu, with the signature \`({ props, event }) => { ... }\` `, portalId: string ` The \`id\` of the FlyOut portal element that is appended to \`document.body\`. Defaults to a generated \`id\`. `, FlyOut: node, Target: node, }; // all of these should be removed, but it's a breaking change to do so :( ContextMenu.UP = EnumDirection.up; ContextMenu.DOWN = EnumDirection.down; ContextMenu.LEFT = EnumDirection.left; ContextMenu.RIGHT = EnumDirection.right; ContextMenu.START = EnumAlignment.start; ContextMenu.CENTER = EnumAlignment.center; ContextMenu.END = EnumAlignment.end; ContextMenu.Target = ContextMenuTarget; ContextMenu.FlyOut = ContextMenuFlyOut; ContextMenu.defaultProps = { direction: 'down', directonOffset: 0, minWidthOffset: 0, alignment: 'start', // no default alignmentOffset so it can default to result of `getAlignmentOffset` getAlignmentOffset: _.constant(0), isExpanded: true, onClickOut: null, portalId: null, }; export default ContextMenu;