@wfp/react
Version:
WFP UI Kit
353 lines (323 loc) • 10.2 kB
JavaScript
import warning from 'warning';
import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
import window from 'window-or-global';
/**
* The structure for the position of floating menu.
* @typedef {Object} FloatingMenu~position
* @property {number} left The left position.
* @property {number} top The top position.
* @property {number} right The right position.
* @property {number} bottom The bottom position.
*/
/**
* The structure for the size of floating menu.
* @typedef {Object} FloatingMenu~size
* @property {number} width The width.
* @property {number} height The height.
*/
/**
* The structure for the position offset of floating menu.
* @typedef {Object} FloatingMenu~offset
* @property {number} top The top position.
* @property {number} left The left position.
*/
export const DIRECTION_LEFT = 'left';
export const DIRECTION_TOP = 'top';
export const DIRECTION_RIGHT = 'right';
export const DIRECTION_BOTTOM = 'bottom';
const hasCreatePortal = typeof ReactDOM.createPortal === 'function';
/**
* @param {FloatingMenu~offset} [oldMenuOffset={}] The old value.
* @param {FloatingMenu~offset} [menuOffset={}] The new value.
* @returns `true` if the parent component wants to change in the adjustment of the floating menu position.
* @private
*/
const hasChangeInOffset = (oldMenuOffset = {}, menuOffset = {}) => {
if (typeof oldMenuOffset !== typeof menuOffset) {
return true;
} else if (
Object(menuOffset) === menuOffset &&
typeof menuOffset !== 'function'
) {
return (
oldMenuOffset.top !== menuOffset.top ||
oldMenuOffset.left !== menuOffset.left
);
}
return oldMenuOffset !== menuOffset;
};
/**
* @param {Object} params The parameters.
* @param {FloatingMenu~size} params.menuSize The size of the menu.
* @param {FloatingMenu~position} params.refPosition The position of the triggering element.
* @param {FloatingMenu~offset} [params.offset={ left: 0, top: 0 }] The position offset of the menu.
* @param {string} [params.direction=bottom] The menu direction.
* @param {number} [params.scrollY=0] The scroll position of the viewport.
* @returns {FloatingMenu~offset} The position of the menu, relative to the top-left corner of the viewport.
* @private
*/
const getFloatingPosition = ({
menuSize,
refPosition,
offset = {},
direction = DIRECTION_BOTTOM,
scrollY = 0,
}) => {
const {
left: refLeft = 0,
top: refTop = 0,
right: refRight = 0,
bottom: refBottom = 0,
} = refPosition;
const { width, height } = menuSize;
const { top = 0, left = 0 } = offset;
const refCenterHorizontal = (refLeft + refRight) / 2;
const refCenterVertical = (refTop + refBottom) / 2;
return {
[DIRECTION_LEFT]: () => ({
left: refLeft - width - left,
top: refCenterVertical - height / 2 + scrollY + top,
}),
[DIRECTION_TOP]: () => ({
left: refCenterHorizontal - width / 2 + left,
top: refTop - height + scrollY - top,
}),
[DIRECTION_RIGHT]: () => ({
left: refRight + left,
top: refCenterVertical - height / 2 + scrollY + top,
}),
[DIRECTION_BOTTOM]: () => ({
left: refCenterHorizontal - width / 2 + left,
top: refBottom + scrollY + top,
}),
}[direction]();
};
/**
* A menu that is detached from the triggering element.
* Useful when the container of the triggering element cannot have `overflow:visible` style, etc.
*/
class FloatingMenu extends React.Component {
static propTypes = {
/**
* Contents to put into the floating menu.
*/
children: PropTypes.object,
/**
* The position in the viewport of the trigger button.
*/
menuPosition: PropTypes.shape({
top: PropTypes.number,
right: PropTypes.number,
bottom: PropTypes.number,
left: PropTypes.number,
}),
/**
* Where to put the tooltip, relative to the trigger button.
*/
menuDirection: PropTypes.oneOf([
DIRECTION_LEFT,
DIRECTION_TOP,
DIRECTION_RIGHT,
DIRECTION_BOTTOM,
]),
/**
* The adjustment of the floating menu position, considering the position of dropdown arrow, etc.
*/
menuOffset: PropTypes.oneOfType([
PropTypes.shape({
top: PropTypes.number,
left: PropTypes.number,
}),
PropTypes.func,
]),
/**
* The additional styles to put to the floating menu.
*/
styles: PropTypes.object,
/**
* The callback called when the menu body has been mounted to/will be unmounted from the DOM.
*/
menuRef: PropTypes.func,
};
static defaultProps = {
menuPosition: {},
menuOffset: {},
menuDirection: DIRECTION_BOTTOM,
};
state = {
/**
* The position of the menu, relative to the top-left corner of the viewport.
* @type {FloatingMenu~offset}
*/
floatingPosition: undefined,
};
/**
* The cached refernce to the menu container.
* Only used if React portal API is not available.
* @type {Element}
* @private
*/
_menuContainer = null;
/**
* The cached refernce to the menu body.
* @type {Element}
* @private
*/
_menuBody = null;
constructor(props) {
super(props);
if (typeof document !== 'undefined' && hasCreatePortal) {
this.el = document.createElement('div');
}
}
/**
* Calculates the position in the viewport of floating menu,
* once this component is mounted or updated upon change in the following props:
*
* * `menuPosition` (The position in the viewport of the trigger button)
* * `menuOffset` (The adjustment that should be applied to the calculated floating menu's position)
* * `menuDirection` (Where the floating menu menu should be placed relative to the trigger button)
*
* @private
*/
_updateMenuSize = (prevProps = {}) => {
const menuBody = this._menuBody;
warning(
menuBody,
'The DOM node for menu body for calculating its position is not available. Skipping...'
);
if (!menuBody) {
return;
}
const {
menuPosition: oldRefPosition = {},
menuOffset: oldMenuOffset = {},
menuDirection: oldMenuDirection,
} = prevProps;
const {
menuPosition: refPosition = {},
menuOffset = {},
menuDirection,
} = this.props;
if (
oldRefPosition.top !== refPosition.top ||
oldRefPosition.right !== refPosition.right ||
oldRefPosition.bottom !== refPosition.bottom ||
oldRefPosition.left !== refPosition.left ||
hasChangeInOffset(oldMenuOffset, menuOffset) ||
oldMenuDirection !== menuDirection
) {
const menuSize = menuBody.getBoundingClientRect();
const offset =
typeof menuOffset !== 'function'
? menuOffset
: menuOffset(menuBody, menuDirection);
// Skips if either in the following condition:
// a) Menu body has `display:none`
// b) `menuOffset` as a callback returns `undefined` (The callback saw that it couldn't calculate the value)
if ((menuSize.width > 0 && menuSize.height > 0) || !offset) {
this.setState({
floatingPosition: getFloatingPosition({
menuSize,
refPosition,
direction: menuDirection,
offset,
scrollY: window.scrollY,
}),
});
}
}
};
componentDidUpdate(prevProps) {
if (!hasCreatePortal) {
ReactDOM.render(this._getChildrenWithProps(), this._menuContainer);
} else {
this._updateMenuSize(prevProps);
}
}
componentDidMount() {
const { menuRef } = this.props;
if (!hasCreatePortal) {
this._menuContainer = document.createElement('div');
document.body.appendChild(this._menuContainer);
const style = {
display: 'block',
opacity: 0,
};
const childrenWithProps = React.cloneElement(this.props.children, {
style,
});
ReactDOM.render(childrenWithProps, this._menuContainer, () => {
this._menuBody = this._menuContainer.firstChild;
this._updateMenuSize();
ReactDOM.render(
this._getChildrenWithProps(),
this._menuContainer,
() => {
menuRef && menuRef(this._menuBody);
}
);
});
} else {
if (this.el && this.el.firstChild) {
this._menuBody = this.el.firstChild;
document.body.appendChild(this._menuBody);
menuRef && menuRef(this._menuBody);
}
this._updateMenuSize();
}
}
componentWillUnmount() {
const { menuRef } = this.props;
menuRef && menuRef(null);
if (!hasCreatePortal) {
const menuContainer = this._menuContainer;
ReactDOM.unmountComponentAtNode(menuContainer);
if (menuContainer && menuContainer.parentNode) {
menuContainer.parentNode.removeChild(menuContainer);
}
this._menuContainer = null;
} else if (this._menuBody) {
// Moves the menu body back to the portal container so that React unmount code does not crash
this.el.appendChild(this._menuBody);
}
}
/**
* @returns The child nodes, with styles containing the floating menu position.
* @private
*/
_getChildrenWithProps = () => {
const { styles, children } = this.props;
const { floatingPosition: pos } = this.state;
// If no pos available, we need to hide the element (offscreen to the left)
// This is done so we can measure the content before positioning it correctly.
const positioningStyle = pos
? {
left: `${pos.left}px`,
top: `${pos.top}px`,
right: 'auto',
}
: {
left: `${window.innerWidth}px`,
top: '0px',
};
return React.cloneElement(children, {
style: {
...styles,
...positioningStyle,
position: 'absolute',
margin: 0,
opacity: 1,
},
});
};
render() {
if (typeof document !== 'undefined' && hasCreatePortal) {
return ReactDOM.createPortal(this._getChildrenWithProps(), this.el);
}
return null;
}
}
export default FloatingMenu;