wix-style-react
Version:
338 lines (285 loc) • 8.82 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import { listItemActionBuilder } from '../ListItemAction';
import DropdownBase from '../DropdownBase';
import { placements } from '../Popover';
import { st, classes } from './PopoverMenu.st.css';
import { listItemSectionBuilder } from '../ListItemSection';
/** PopoverMenu */
class PopoverMenu extends React.PureComponent {
static displayName = 'PopoverMenu';
static MenuItem = () => ({});
static Divider = () => ({});
static propTypes = {
/** The maximum width applied to the list */
maxWidth: PropTypes.number,
/** The minimum width applied to the list */
minWidth: PropTypes.number,
/** The maximum height value applied to the list */
maxHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/** Popover content z-index */
zIndex: PropTypes.number,
/** Moves popover content relative to the parent by x or y */
moveBy: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number }),
/** Element to trigger the popover */
triggerElement: PropTypes.oneOfType([PropTypes.element, PropTypes.func])
.isRequired,
/** The Popover's placement:
* * auto-start
* * auto
* * auto-end
* * top-start
* * top
* * top-end
* * right-start
* * right
* * right-end
* * bottom-end
* * bottom
* * bottom-start
* * left-end
* * left
* * left-start
*/
placement: PropTypes.oneOf(placements),
/** Changing text size */
textSize: PropTypes.oneOf(['small', 'medium']),
/** Enables text ellipsis on tight containers */
ellipsis: PropTypes.bool,
/**
* `<PopoverMenu.MenuItem>` components that has these fields:
* * `text` - Item text
* * `onClick` - Callback to be triggered on item click
* * `skin` - Item theme (standard, dark, destructive)
* * `prefixIcon` - Prefix icon
* * `dataHook` - Hook for testing purposes
* * `disabled` - Disabled
*/
children: PropTypes.node,
/** The Popover's appendTo */
appendTo: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
/**
* Whether to enable the flip behaviour. This behaviour is used to flip the `<Popover/>`'s placement
* when it starts to overlap the target element (`<Popover.Element/>`).
*/
flip: PropTypes.bool,
/**
* Whether to enable the fixed behaviour. This behaviour is used to keep the `<Popover/>` at it's
* original placement even when it's being positioned outside the boundary.
*/
fixed: PropTypes.bool,
/* stretch trigger element to the width of its container. */
fluid: PropTypes.bool,
/** Whether to show the Popover's arrow */
showArrow: PropTypes.bool,
/** Applied as data-hook HTML attribute that can be used in the tests*/
dataHook: PropTypes.string,
/** A single CSS class name to be appended to the root element. */
className: PropTypes.string,
/** Defines a callback function which is called when dropdown is opened */
onShow: PropTypes.func,
/** Defines a callback function which is called when dropdown is closed */
onHide: PropTypes.func,
};
static defaultProps = {
maxWidth: 204,
minWidth: 144,
placement: 'bottom',
appendTo: 'window',
textSize: 'medium',
fixed: true,
flip: true,
showArrow: true,
ellipsis: false,
maxHeight: 'auto',
};
savedOnClicks = null;
focusableList = [];
children = {};
state = {
focused: 0,
};
_onSelect = e => {
const onClick = this.savedOnClicks.find(({ id }) => id === e.id).onClick;
onClick && onClick();
};
_onKeyDown = (e, id) => {
const ARROW_LEFT = 37;
const ARROW_UP = 38;
const ARROW_RIGHT = 39;
const ARROW_DOWN = 40;
const length = this.focusableList.length;
let focused = this.state.focused;
const keyCode = e.keyCode;
if (keyCode === ARROW_LEFT || keyCode === ARROW_UP) {
if (id === 0) {
focused = this.focusableList[length - 1];
} else {
const nextIndex = this.focusableList.indexOf(id) - 1;
focused = this.focusableList[nextIndex];
}
}
if (keyCode === ARROW_RIGHT || keyCode === ARROW_DOWN) {
if (id === length - 1) {
focused = this.focusableList[0];
} else {
const nextIndex = this.focusableList.indexOf(id) + 1;
focused = this.focusableList[nextIndex];
}
}
if (focused !== this.state.focused) {
this._focus(e, focused);
}
};
_focus = (e, focused) => {
e.preventDefault();
const native = this.children[focused].focus;
const focusableHOC = this.children[focused].wrappedComponentRef;
const callback = native
? this.children[focused].focus
: focusableHOC
? focusableHOC.innerComponentRef.focus
: () => ({});
this.setState({ focused }, () => callback());
};
_filterChildren = children => {
return React.Children.map(children, child => child).filter(
child => typeof child !== 'string',
);
};
_buildOptions = children => {
return children.map((child, id) => {
const displayName = child.type && child.type.displayName;
if (displayName && displayName === 'PopoverMenu.Divider') {
return {
id,
divider: true,
dataHook: child.props.dataHook,
};
}
if (displayName && displayName === 'PopoverMenu.MenuItem') {
return {
id,
title: child.props.text,
onClick: child.props.onClick,
skin: child.props.skin,
dataHook: child.props.dataHook,
prefixIcon: child.props.prefixIcon,
disabled: child.props.disabled,
subtitle: child.props.subtitle,
};
}
return { id, value: child, custom: true, overrideStyle: true };
});
};
_saveOnClicks = options => {
this.savedOnClicks = options.map(({ id, onClick }) => ({ id, onClick }));
};
_renderOptions = () => {
const { textSize, ellipsis } = this.props;
const children = this._filterChildren(this.props.children);
const options = this._buildOptions(children);
// Store information for further use
this._saveOnClicks(options);
return options.map(option => {
// Custom
if (option.custom) {
return option;
}
// Divider
if (option.divider) {
return listItemSectionBuilder({
type: 'divider',
...option,
});
}
const { id, disabled, onClick, dataHook, skin, subtitle, ...rest } =
option;
const { focused } = this.state;
if (!disabled) {
this.focusableList = [...this.focusableList, id];
}
return listItemActionBuilder({
...rest,
id,
disabled,
as: 'button',
dataHook: dataHook || `popover-menu-${id}`,
ref: ref => (this.children[id] = ref),
tabIndex: id === focused && !disabled ? '0' : '-1',
onKeyDown: e => this._onKeyDown(e, id),
skin: skin || 'dark',
size: textSize,
className: classes.listItem,
ellipsis,
subtitle,
});
});
};
_renderTriggerElement = ({ toggle, open, close, isOpen }) => {
const { triggerElement } = this.props;
if (!triggerElement) {
return null;
}
return React.isValidElement(triggerElement)
? React.cloneElement(triggerElement, {
onClick: toggle,
})
: triggerElement({
onClick: toggle,
toggle,
open,
close,
isOpen,
});
};
render() {
const {
appendTo,
placement,
minWidth,
maxWidth,
flip,
fixed,
showArrow,
dataHook,
moveBy,
maxHeight,
zIndex,
className,
fluid,
onHide,
onShow,
} = this.props;
return (
<DropdownBase
className={st(classes.root, className)}
dataHook={dataHook}
animate
options={this._renderOptions()}
onSelect={this._onSelect}
appendTo={appendTo}
placement={placement}
minWidth={minWidth}
maxWidth={maxWidth}
flip={flip}
fixed={fixed}
showArrow={showArrow}
tabIndex={-1}
moveBy={moveBy}
maxHeight={maxHeight}
zIndex={zIndex}
fluid={fluid}
onHide={onHide}
onShow={onShow}
>
{({ toggle, open, close, isOpen }) =>
this._renderTriggerElement({ toggle, open, close, isOpen })
}
</DropdownBase>
);
}
}
PopoverMenu.MenuItem.displayName = 'PopoverMenu.MenuItem';
PopoverMenu.Divider.displayName = 'PopoverMenu.Divider';
export default PopoverMenu;