lucid-ui
Version:
A UI component library from AppNexus.
342 lines (341 loc) • 15 kB
JavaScript
import _ from 'lodash';
import React from 'react';
import PropTypes from 'react-peek/prop-types';
import { lucidClassNames } from '../../util/style-helpers';
import { filterTypes, omitProps, } from '../../util/component-types';
import DragCaptureZone from '../DragCaptureZone/DragCaptureZone';
import { Motion, spring } from 'react-motion';
import { QUICK_SLIDE_MOTION } from '../../constants/motion-spring';
const cx = lucidClassNames.bind('&-SplitHorizontal');
const { bool, func, node, number, string, oneOfType } = PropTypes;
export const SplitHorizontalTopPane = (_props) => null;
SplitHorizontalTopPane.displayName = 'SplitHorizontal.TopPane';
SplitHorizontalTopPane.peek = {
description: `Top pane of the split.`,
};
SplitHorizontalTopPane.propName = 'TopPane';
SplitHorizontalTopPane.propTypes = {
children: node `
Any valid React children.
`,
height: oneOfType([number, string]) `
Set height of this pane.
`,
isPrimary: bool `
Define this pane as the primary content pane. When the split is
collapsed, this pane becomes full height.
`,
};
SplitHorizontalTopPane.defaultProps = {
isPrimary: false,
};
const SplitHorizontalBottomPane = (_props) => null;
SplitHorizontalBottomPane.displayName = 'SplitHorizontal.BottomPane';
SplitHorizontalBottomPane.peek = {
description: `
Bottom pane of the split.
`,
};
SplitHorizontalBottomPane.propName = 'BottomPane';
SplitHorizontalBottomPane.propTypes = {
children: node `
Any valid React children.
`,
height: oneOfType([number, string]) `
Set height of this pane.
`,
isPrimary: bool `
Define this pane as the primary content pane. When the split is
collapsed, this pane becomes full height.
`,
};
SplitHorizontalBottomPane.defaultProps = {
isPrimary: false,
};
const SplitHorizontalDivider = (_props) => null;
SplitHorizontalDivider.displayName = 'SplitHorizontal.Divider';
SplitHorizontalDivider.peek = {
description: `
The area that separates the split panes. Can be dragged to resize
them.
`,
};
SplitHorizontalDivider.propName = 'Divider';
SplitHorizontalDivider.propTypes = {
children: node `
Any valid React children.
`,
};
class SplitHorizontal extends React.Component {
constructor() {
super(...arguments);
this.state = {
collapseAmount: 250,
isAnimated: false,
isExpanded: false,
};
this.innerRef = React.createRef();
this.topPaneRef = React.createRef();
this.bottomPaneRef = React.createRef();
this.secondaryStartRect = this.topPaneRef.current
? this.topPaneRef.current.getBoundingClientRect()
: null;
this.getPanes = () => {
const { children } = this.props;
const { topPaneRef, bottomPaneRef } = this;
const topPaneElement = _.get(filterTypes(children, SplitHorizontal.TopPane), 0, React.createElement(SplitHorizontal.TopPane, null));
const bottomPaneElement = _.get(filterTypes(children, SplitHorizontal.BottomPane), 0, React.createElement(SplitHorizontal.BottomPane, null));
let primaryElement, primaryRef;
let secondaryElement, secondaryRef;
if (topPaneElement.props.isPrimary && !bottomPaneElement.props.isPrimary) {
primaryElement = topPaneElement;
primaryRef = topPaneRef;
secondaryElement = bottomPaneElement;
secondaryRef = bottomPaneRef;
}
else {
primaryElement = bottomPaneElement;
primaryRef = bottomPaneRef;
secondaryElement = topPaneElement;
secondaryRef = topPaneRef;
}
return {
top: topPaneElement.props,
bottom: bottomPaneElement.props,
primary: primaryElement.props,
primaryRef,
secondary: secondaryElement.props,
secondaryRef,
};
};
this.panes = this.getPanes();
// Style changes to DOM nodes are updated here to shortcut the state -> render cycle for better performance. Also the Style updates in this
// function are entirely transient and can be flushed with a props update to `height`.
this.applyDeltaToSecondaryHeight = (dY, isExpanded, secondaryStartRect, secondaryRef, secondary, bottom, innerRef, primaryRef, collapseShift = 0) => {
if (isExpanded) {
secondaryRef.current.style.flexBasis = `${secondaryStartRect.height +
dY * (secondary === bottom ? -1 : 1)}px`;
return secondaryStartRect.height + dY * (secondary === bottom ? -1 : 1);
}
else {
const overlapHeight = (secondary === bottom
? secondaryStartRect.height + dY
: secondaryStartRect.height - dY) - collapseShift;
if (overlapHeight > 0) {
this.collapseSecondary(overlapHeight);
return secondaryStartRect.height - overlapHeight;
}
else {
this.expandSecondary();
secondaryRef.current.style.flexBasis = `${(dY +
collapseShift) *
(secondary === bottom ? -1 : 1)}px`;
return (dY + collapseShift) * (secondary === bottom ? -1 : 1);
}
}
};
this.expandSecondary = () => {
this.setState({ isExpanded: true });
};
this.collapseSecondary = (collapseAmount) => {
this.setState({ isExpanded: false, collapseAmount });
};
this.disableAnimation = (innerRef, secondaryRef, primaryRef) => {
innerRef.current.style.transition = 'all 0s';
secondaryRef.current.style.transition = 'all 0s';
primaryRef.current.style.transition = 'all 0s';
};
this.resetAnimation = (innerRef, secondaryRef, primaryRef) => {
innerRef.current.style.transition = '';
secondaryRef.current.style.transition = '';
primaryRef.current.style.transition = '';
};
this.handleDragStart = () => {
this.panes = this.getPanes();
const { secondaryRef, primaryRef } = this.panes;
this.secondaryStartRect = secondaryRef.current
? secondaryRef.current.getBoundingClientRect()
: null;
this.disableAnimation(this.innerRef, secondaryRef, primaryRef);
};
this.handleDrag = ({ dY }, { event }) => {
const { isExpanded, collapseShift, onResizing } = this.props;
const { secondaryRef, secondary, bottom, primaryRef } = this.panes;
this.secondaryStartRect &&
onResizing(this.applyDeltaToSecondaryHeight(dY, isExpanded, this.secondaryStartRect, secondaryRef, secondary, bottom, this.innerRef, primaryRef, collapseShift), { event, props: this.props });
};
this.handleDragEnd = ({ dY }, { event }) => {
const { isExpanded, collapseShift, onResize } = this.props;
const { secondaryRef, secondary, bottom, primaryRef } = this.panes;
this.secondaryStartRect &&
onResize(this.applyDeltaToSecondaryHeight(dY, isExpanded, this.secondaryStartRect, secondaryRef, secondary, bottom, this.innerRef, primaryRef, collapseShift), { event, props: this.props });
this.resetAnimation(this.innerRef, secondaryRef, primaryRef);
};
}
UNSAFE_componentWillReceiveProps(nextProps) {
const { isAnimated, isExpanded, collapseShift } = nextProps;
const { secondaryRef } = this.getPanes();
if (!isExpanded && // check if collapseShift changed or secondary pane collapsed
(this.props.isExpanded || this.props.collapseShift !== collapseShift)) {
// collapse secondary
const secondaryRect = secondaryRef.current
? secondaryRef.current.getBoundingClientRect()
: null;
secondaryRect &&
this.collapseSecondary(secondaryRect.height - collapseShift);
}
else if (!this.props.isExpanded && isExpanded) {
// expand secondary
this.expandSecondary();
}
if (this.state.isAnimated !== isAnimated) {
this.setState({
isAnimated,
});
}
}
componentDidMount() {
const { isAnimated, isExpanded, collapseShift } = this.props;
const { secondaryRef } = this.getPanes();
if (isExpanded) {
// expand secondary
this.expandSecondary();
}
else {
// collapse secondary
const secondaryRect = secondaryRef.current
? secondaryRef.current.getBoundingClientRect()
: null;
secondaryRect &&
this.collapseSecondary(secondaryRect.height - collapseShift);
}
if (this.state.isAnimated !== isAnimated) {
_.defer(() => {
this.setState({
isAnimated,
});
});
}
}
render() {
const { children, className, ...passThroughs } = this.props;
const { isAnimated, isExpanded, collapseAmount } = this.state;
const { top: topPaneProps, bottom: bottomPaneProps, secondary, } = this.getPanes();
const dividerProps = _.get(_.first(filterTypes(children, SplitHorizontalDivider)), 'props', {});
let from, to;
if (!isExpanded) {
from = { slideAmount: 0 };
to = { slideAmount: collapseAmount };
}
else {
from = { slideAmount: 0 };
to = { slideAmount: 0 };
}
const isBottomSecondary = bottomPaneProps === secondary;
return (React.createElement("div", Object.assign({}, omitProps(this.props, undefined, Object.keys(SplitHorizontal.propTypes)), { className: cx('&', {
'&-is-expanded': this.props.isExpanded,
'&-is-animated': this.props.isAnimated,
}, className), style: {
flex: 1,
overflow: 'hidden',
...passThroughs.style,
} }),
React.createElement(Motion, { defaultStyle: from, style: isAnimated
? _.mapValues(to, (val) => spring(val, QUICK_SLIDE_MOTION))
: to }, (tween) => (React.createElement("div", { className: cx('&-inner'), ref: this.innerRef, style: {
height: '100%',
display: 'flex',
flexDirection: 'column',
transform: `translateY(${(isBottomSecondary ? 1 : -1) *
Math.round(tween.slideAmount)}px)`,
} },
React.createElement("div", Object.assign({}, omitProps(topPaneProps, undefined, _.keys(SplitHorizontalTopPane.propTypes)), { className: cx('&-TopPane', {
'&-is-secondary': topPaneProps === secondary,
}, topPaneProps.className), style: {
flexGrow: isBottomSecondary ? 1 : 0,
flexShrink: isBottomSecondary ? 1 : 0,
flexBasis: _.isNil(topPaneProps.height)
? topPaneProps === secondary
? 'calc(50% - 3px)'
: '0%'
: topPaneProps.height,
marginTop: isBottomSecondary
? -Math.round(tween.slideAmount)
: undefined,
overflow: 'auto',
...topPaneProps.style,
}, ref: this.topPaneRef }), topPaneProps.children),
React.createElement(DragCaptureZone, Object.assign({}, omitProps(dividerProps, undefined, _.keys(SplitHorizontalDivider.propTypes), false), { className: cx('&-Divider', dividerProps.className), onDragStart: this.handleDragStart, onDrag: this.handleDrag, onDragEnd: this.handleDragEnd, style: {
height: '6px',
boxSizing: 'border-box',
...dividerProps.style,
} }), dividerProps.children || ' '),
React.createElement("div", Object.assign({}, omitProps(bottomPaneProps, undefined, _.keys(SplitHorizontalBottomPane.propTypes)), { className: cx('&-BottomPane', {
'&-is-secondary': bottomPaneProps === secondary,
}, bottomPaneProps.className), style: {
flexGrow: !isBottomSecondary ? 1 : 0,
flexShrink: !isBottomSecondary ? 1 : 0,
flexBasis: _.isNil(bottomPaneProps.height)
? bottomPaneProps === secondary
? 'calc(50% - 3px)'
: '0%'
: bottomPaneProps.height,
marginBottom: isBottomSecondary
? undefined
: -Math.round(tween.slideAmount),
overflow: 'auto',
...bottomPaneProps.style,
}, ref: this.bottomPaneRef }), bottomPaneProps.children))))));
}
}
SplitHorizontal.displayName = 'SplitHorizontal';
SplitHorizontal.peek = {
description: `
\`SplitHorizontal\` renders a vertical split.
`,
categories: ['helpers'],
madeFrom: ['DragCaptureZone'],
};
SplitHorizontal._isPrivate = true;
SplitHorizontal.propTypes = {
className: string `
Appended to the component-specific class names set on the root element.
Value is run through the \`classnames\` library.
`,
children: node `
Direct children must be types {Splitvertical.Toppane,
Splitvertical.Divider, Splitvertical.BottomPane}. All content is
composed as children of these respective elements.
`,
isExpanded: bool `
Render as expanded or collapsed.
`,
isAnimated: bool `
Allows animated expand and collapse behavior.
`,
onResizing: func `
Called when the user is currently resizing the split with the Divider.
Signature: \`(height, { event, props }) => {}\`
`,
onResize: func `
Called when the user resizes the split with the Divider. Signature:
\`(height, { event, props }) => {}\`
`,
collapseShift: number `
Use this prop to shift the collapsed position by a known value.
`,
TopPane: node,
BottomPane: node,
Divider: node,
};
SplitHorizontal.defaultProps = {
isExpanded: true,
isAnimated: false,
collapseShift: 0,
onResizing: _.noop,
onResize: _.noop,
};
SplitHorizontal.TopPane = SplitHorizontalTopPane;
SplitHorizontal.BottomPane = SplitHorizontalBottomPane;
SplitHorizontal.Divider = SplitHorizontalDivider;
export default SplitHorizontal;