Web:Components: create new-context-menu

This commit is contained in:
Timofey Boyko 2021-10-29 16:30:00 +08:00
parent 20dcdde09b
commit 1e4a708dd1
6 changed files with 584 additions and 2 deletions

View File

@ -0,0 +1,34 @@
# ContextMenu
ContextMenu is used for a call context actions on a page.
> Implemented as part of RowContainer component.
### Usage
```js
import NewContextMenu from "@appserver/components/new-context-menu";
```
```jsx
<NewContextMenu model={defaultModel} />
```
For use within separate component it is necessary to determine active zone and events for calling and transferring options in menu.
In particular case, state is created containing options for particular Row element and passed to component when called.
ContextMenu contain MenuItem component and can take from the props model(all view)
and header(show only tablet or mobile, when view changed).
### Properties
| Props | Type | Required | Values | Default | Description |
| -------------- | :------------: | :------: | :----: | :-----------: | ------------------------ |
| `className` | `string` | - | - | - | Accepts class |
| `id` | `string` | - | - | `contextMenu` | Accepts id |
| `model` | `array` | - | - | `[ ]` | Items collection |
| `header` | `object` | - | - | `{}` | ContextMenu header |
| `style` | `obj`, `array` | - | - | - | Accepts css style |
| `targetAreaId` | `string` | - | - | - | Id of container apply to |
| `withBackdrop` | `bool` | - | - | `true` | Used to display backdrop |

View File

@ -0,0 +1,432 @@
import React from "react";
import PropTypes from "prop-types";
import DomHelpers from "../utils/domHelpers";
import { classNames } from "../utils/classNames";
import { CSSTransition } from "react-transition-group";
import { VariableSizeList } from "react-window";
import CustomScrollbarsVirtualList from "../scrollbar/custom-scrollbars-virtual-list";
import { isMobile, isMobileOnly } from "react-device-detect";
import {
isMobile as isMobileUtils,
isTablet as isTabletUtils,
} from "../utils/device";
import Portal from "../portal";
import MenuItem from "../menu-item";
import StyledContextMenu from "./styled-new-context-menu";
import Backdrop from "../backdrop";
// eslint-disable-next-line react/display-name, react/prop-types
const Row = React.memo(({ data, index, style }) => {
if (!data[index]) return null;
return (
<MenuItem
// eslint-disable-next-line react/prop-types
key={data[index].key}
// eslint-disable-next-line react/prop-types
icon={data[index].icon}
// eslint-disable-next-line react/prop-types
label={data[index].label}
// eslint-disable-next-line react/prop-types
isSeparator={data[index].isSeparator}
// eslint-disable-next-line react/prop-types
onClick={data[index].onClick}
style={style}
/>
);
});
class NewContextMenu extends React.Component {
constructor(props) {
super(props);
this.state = {
visible: false,
reshow: false,
resetMenu: false,
changeView: false,
};
this.onMenuClick = this.onMenuClick.bind(this);
this.onLeafClick = this.onLeafClick.bind(this);
this.onMenuMouseEnter = this.onMenuMouseEnter.bind(this);
this.onEnter = this.onEnter.bind(this);
this.onEntered = this.onEntered.bind(this);
this.onExit = this.onExit.bind(this);
this.onExited = this.onExited.bind(this);
this.menuRef = React.createRef();
}
onMenuClick() {
this.setState({
resetMenu: false,
});
}
onMenuMouseEnter() {
this.setState({
resetMenu: false,
});
}
show(e) {
if (!(e instanceof Event)) {
e.persist();
}
e.stopPropagation();
e.preventDefault();
this.currentEvent = e;
if (this.state.visible) {
this.setState({ reshow: true });
} else {
this.setState({ visible: true }, () => {
if (this.props.onShow) {
this.props.onShow(this.currentEvent);
}
});
}
}
componentDidUpdate(prevProps, prevState) {
if (this.state.visible && prevState.reshow !== this.state.reshow) {
let event = this.currentEvent;
this.setState(
{
visible: false,
reshow: false,
rePosition: false,
changeView: false,
resetMenu: true,
},
() => this.show(event)
);
}
}
hide(e) {
if (!(e instanceof Event)) {
e.persist();
}
this.currentEvent = e;
this.setState({ visible: false, reshow: false, changeView: false }, () => {
if (this.props.onHide) {
this.props.onHide(this.currentEvent);
}
});
}
onEnter() {
if (this.props.autoZIndex) {
this.menuRef.current.style.zIndex = String(
this.props.baseZIndex + DomHelpers.generateZIndex()
);
}
this.position(this.currentEvent);
}
onEntered() {
this.bindDocumentListeners();
}
onExit() {
this.currentEvent = null;
this.unbindDocumentListeners();
}
onExited() {
DomHelpers.revertZIndex();
}
position(event) {
if (event) {
let left = event.pageX + 1;
let top = event.pageY + 1;
let width = this.menuRef.current.offsetParent
? this.menuRef.current.offsetWidth
: DomHelpers.getHiddenElementOuterWidth(this.menuRef.current);
let height = this.menuRef.current.offsetParent
? this.menuRef.current.offsetHeight
: DomHelpers.getHiddenElementOuterHeight(this.menuRef.current);
let viewport = DomHelpers.getViewport();
if ((isMobile || isTabletUtils()) && height > 483) {
this.setState({ changeView: true });
return;
}
if ((isMobileOnly || isMobileUtils()) && height > 210) {
this.setState({ changeView: true });
return;
}
//flip
if (left + width - document.body.scrollLeft > viewport.width) {
left -= width;
}
//flip
if (top + height - document.body.scrollTop > viewport.height) {
top -= height;
}
//fit
if (left < document.body.scrollLeft) {
left = document.body.scrollLeft;
}
//fit
if (top < document.body.scrollTop) {
top = document.body.scrollTop;
}
this.menuRef.current.style.left = left + "px";
this.menuRef.current.style.top = top + "px";
}
}
onLeafClick(e) {
this.setState({
resetMenu: true,
});
this.hide(e);
e.stopPropagation();
}
isOutsideClicked(e) {
return (
this.menuRef &&
this.menuRef.current &&
!(
this.menuRef.current.isSameNode(e.target) ||
this.menuRef.current.contains(e.target)
)
);
}
bindDocumentListeners() {
this.bindDocumentResizeListener();
this.bindDocumentClickListener();
}
unbindDocumentListeners() {
this.unbindDocumentResizeListener();
this.unbindDocumentClickListener();
}
bindDocumentClickListener() {
if (!this.documentClickListener) {
this.documentClickListener = (e) => {
if (this.isOutsideClicked(e)) {
//TODO: (&& e.button !== 2) restore after global usage
this.hide(e);
this.setState({
resetMenu: true,
});
}
};
document.addEventListener("click", this.documentClickListener);
document.addEventListener("mousedown", this.documentClickListener);
}
}
bindDocumentContextMenuListener() {
if (!this.documentContextMenuListener) {
this.documentContextMenuListener = (e) => {
this.show(e);
};
document.addEventListener(
"contextmenu",
this.documentContextMenuListener
);
}
}
bindDocumentResizeListener() {
if (!this.documentResizeListener) {
this.documentResizeListener = (e) => {
if (this.state.visible) {
this.hide(e);
}
};
window.addEventListener("resize", this.documentResizeListener);
}
}
unbindDocumentClickListener() {
if (this.documentClickListener) {
document.removeEventListener("click", this.documentClickListener);
document.removeEventListener("mousedown", this.documentClickListener);
this.documentClickListener = null;
}
}
unbindDocumentContextMenuListener() {
if (this.documentContextMenuListener) {
document.removeEventListener(
"contextmenu",
this.documentContextMenuListener
);
this.documentContextMenuListener = null;
}
}
unbindDocumentResizeListener() {
if (this.documentResizeListener) {
window.removeEventListener("resize", this.documentResizeListener);
this.documentResizeListener = null;
}
}
componentDidMount() {
if (this.props.global) {
this.bindDocumentContextMenuListener();
}
}
componentWillUnmount() {
this.unbindDocumentListeners();
this.unbindDocumentContextMenuListener();
DomHelpers.revertZIndex();
}
renderContextMenuItems() {
if (!this.props.model) return null;
if (this.state.changeView) {
const rowHeights = this.props.model.map((item, index) => {
if (!item) return 0;
if (item.isSeparator) return 13;
return 36;
});
const getItemSize = (index) => rowHeights[index];
const height = rowHeights.reduce((a, b) => a + b);
const viewport = DomHelpers.getViewport();
const listHeight =
height + 61 > viewport.height - 64 ? viewport.height - 125 : height;
return (
<VariableSizeList
height={listHeight}
width={"auto"}
itemCount={this.props.model.length}
itemSize={getItemSize}
itemData={this.props.model}
outerElementType={CustomScrollbarsVirtualList}
>
{Row}
</VariableSizeList>
);
} else {
const items = this.props.model.map((item, index) => {
if (!item) return;
return (
<MenuItem
key={item.key}
icon={item.icon}
label={item.label}
isSeparator={item.isSeparator}
onClick={item.onClick}
/>
);
});
return items;
}
}
renderContextMenu() {
const className = classNames("p-contextmenu", this.props.className);
const items = this.renderContextMenuItems();
return (
<StyledContextMenu changeView={this.state.changeView}>
<Backdrop visible={this.state.visible} />
<CSSTransition
nodeRef={this.menuRef}
classNames="p-contextmenu"
in={this.state.visible}
timeout={{ enter: 250, exit: 0 }}
unmountOnExit
onEnter={this.onEnter}
onEntered={this.onEntered}
onExit={this.onExit}
onExited={this.onExited}
>
<div
ref={this.menuRef}
id={this.props.id}
className={className}
style={this.props.style}
onClick={this.onMenuClick}
onMouseEnter={this.onMenuMouseEnter}
>
{items}
</div>
</CSSTransition>
</StyledContextMenu>
);
}
render() {
const element = this.renderContextMenu();
return <Portal element={element} appendTo={this.props.appendTo} />;
}
}
NewContextMenu.propTypes = {
/** Unique identifier of the element */
id: PropTypes.string,
/** An array of menuitems */
model: PropTypes.array,
/** Inline style of the component */
style: PropTypes.object,
/** Style class of the component */
className: PropTypes.string,
/** Attaches the menu to document instead of a particular item */
global: PropTypes.bool,
/** Base zIndex value to use in layering */
autoZIndex: PropTypes.bool,
/** Whether to automatically manage layering */
baseZIndex: PropTypes.number,
/** DOM element instance where the menu should be mounted */
appendTo: PropTypes.any,
/** Callback to invoke when a popup menu is shown */
onShow: PropTypes.func,
/** Callback to invoke when a popup menu is hidden */
onHide: PropTypes.func,
};
NewContextMenu.defaultProps = {
id: null,
model: null,
style: null,
className: null,
global: false,
autoZIndex: true,
baseZIndex: 0,
appendTo: null,
onShow: null,
onHide: null,
};
export default NewContextMenu;

View File

@ -0,0 +1,48 @@
import React from "react";
import NewContextMenu from ".";
export default {
title: "Components/NewContextMenu",
component: NewContextMenu,
parameters: {
docs: {
description: {
component: `Is a ContextMenu component.
ContextMenu contain MenuItem component and can take from the props model(all view)
and header(show only tablet or mobile, when view changed).
`,
},
},
},
};
const defaultModel = [
{
disabled: false,
icon: "/static/images/access.edit.react.svg",
key: "edit",
label: "Edit",
onClick: () => console.log("item 1 clicked"),
},
{
disabled: false,
icon: "/static/images/eye.react.svg",
key: "preview",
label: "Preview",
onClick: () => console.log("item 2 clicked"),
},
{ isSeparator: true, key: "separator0" },
{
disabled: false,
icon: "/static/images/catalog.shared.react.svg",
key: "sharing-settings",
label: "Sharing settings",
onClick: () => console.log("item 3 clicked"),
},
];
const Template = () => {
return <NewContextMenu model={defaultModel} />;
};
export const Default = Template.bind({});

View File

@ -0,0 +1,11 @@
import React from "react";
import { mount, shallow } from "enzyme";
import NewContextMenu from ".";
describe("<MenuItem />", () => {
it("renders without error", () => {
const wrapper = mount(<NewContextMenu />);
expect(wrapper).toExist();
});
});

View File

@ -0,0 +1,57 @@
import styled, { css } from "styled-components";
import { isMobile, isMobileOnly } from "react-device-detect";
import { tablet, mobile } from "../utils/device";
import Base from "../themes/base";
const styledTabletView = css`
width: 375px;
max-width: 375px;
max-height: calc(100vh - 64px);
left: 0;
right: 0;
bottom: 0;
margin: 0 auto;
`;
const styledMobileView = css`
width: 100vw;
max-width: 100vw;
max-height: calc(100vh - 64px);
left: 0;
bottom: 0;
`;
const StyledContextMenu = styled.div`
.p-contextmenu {
position: absolute;
background: #ffffff;
border-radius: 6px;
-moz-border-radius: 6px;
-webkit-border-radius: 6px;
box-shadow: 0px 12px 40px rgba(4, 15, 27, 0.12);
-moz-box-shadow: 0px 12px 40px rgba(4, 15, 27, 0.12);
-webkit-box-shadow: 0px 12px 40px rgba(4, 15, 27, 0.12);
padding: 6px 0px;
@media ${tablet} {
${(props) => props.changeView && !isMobile && styledTabletView}
}
@media ${mobile} {
${(props) => props.changeView && !isMobile && styledMobileView}
}
${(props) =>
props.changeView
? isMobileOnly
? styledMobileView
: styledTabletView
: null}
}
`;
StyledContextMenu.defaultProps = {
theme: Base,
};
export default StyledContextMenu;

View File

@ -3,7 +3,7 @@ import React from "react";
import Checkbox from "../checkbox";
import ContextMenuButton from "../context-menu-button";
import ContextMenu from "../context-menu";
import NewContextMenu from "../new-context-menu";
import {
StyledOptionButton,
StyledContentElement,
@ -113,7 +113,7 @@ class Row extends React.Component {
) : (
<div className="expandButton"> </div>
)}
<ContextMenu model={contextOptions} ref={this.cm}></ContextMenu>
<NewContextMenu model={contextOptions} ref={this.cm}></NewContextMenu>
</StyledOptionButton>
</StyledRow>
);