lucid-ui
Version:
A UI component library from AppNexus.
350 lines (349 loc) • 14.4 kB
JavaScript
/* 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;