Skip to content

Commit

Permalink
feat: develop contextMenu (#240)
Browse files Browse the repository at this point in the history
* chore: remove contextMenuCombiner component

* refactor: replace contextMenu with dropdown

* fix: improve ci

* test(contextMenu): update test cases

* fix: support popconfirm in ContextMenu

* test: update tests

* fix: prevent render dropdown with empty data
  • Loading branch information
mortalYoung committed Nov 15, 2022
1 parent d4e6496 commit dadfedc
Show file tree
Hide file tree
Showing 10 changed files with 378 additions and 684 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`test contextMenu should match snapshot 1`] = `
<DocumentFragment>
<span
class="ant-dropdown-trigger ant-dropdown-open"
>
<span
data-testid="test"
>
test
</span>
</span>
<div
style="position: absolute; top: 0px; left: 0px; width: 100%;"
>
<div>
<div
class="ant-dropdown"
style="opacity: 0;"
>
<ul
class="ant-dropdown-menu ant-dropdown-menu-root ant-dropdown-menu-vertical ant-dropdown-menu-light dt-contextMenu-menu"
data-menu-list="true"
role="menu"
tabindex="0"
>
<li
class="ant-dropdown-menu-item ant-dropdown-menu-item-only-child"
data-menu-id="rc-menu-uuid-test-test"
role="menuitem"
tabindex="-1"
>
<span
class="ant-dropdown-menu-title-content"
>
test
</span>
</li>
<li
class="ant-dropdown-menu-item ant-dropdown-menu-item-only-child"
data-menu-id="rc-menu-uuid-test-confirm"
role="menuitem"
tabindex="-1"
>
<span
class="ant-dropdown-menu-title-content"
>
<div>
confirm
</div>
</span>
</li>
</ul>
<div
aria-hidden="true"
style="display: none;"
/>
</div>
</div>
</div>
</DocumentFragment>
`;
71 changes: 71 additions & 0 deletions src/components/contextMenu/__tests__/contextMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react';
import { cleanup, fireEvent, render } from '@testing-library/react';
import ContextMenu from '..';

describe('test contextMenu', () => {
afterEach(cleanup);

test('should match snapshot', () => {
const { asFragment, getByTestId } = render(
<ContextMenu
data={[
{ text: 'test', key: 'test', cb: () => {} },
{
text: 'confirm',
key: 'confirm',
confirm: true,
confirmProps: {
title: 'confirmTitle',
getPopupContainer: (node) => node.parentElement,
},
},
]}
getPopupContainer={(node) => node.parentElement}
>
<span data-testid="test">test</span>
</ContextMenu>
);

fireEvent.contextMenu(getByTestId('test').parentElement);

expect(asFragment()).toMatchSnapshot();
});

test('should support to call cb function on data', () => {
const testFn = jest.fn();
const { getByTestId, container } = render(
<ContextMenu
data={[{ text: 'test', key: 'test', cb: testFn }]}
getPopupContainer={(node) => node.parentElement}
>
<span data-testid="test">test</span>
</ContextMenu>
);

fireEvent.contextMenu(getByTestId('test').parentElement);

const dom = container.querySelector('.ant-dropdown-menu-title-content');

expect(dom).not.toBe(null);

fireEvent.click(dom);

expect(testFn).toBeCalledTimes(1);
});

test('should support wrapperClassName', () => {
const { getByTestId } = render(
<ContextMenu
data={[{ text: 'test', key: 'test', cb: () => {} }]}
getPopupContainer={(node) => node.parentElement}
wrapperClassName="wrapper"
>
<span data-testid="test">test</span>
</ContextMenu>
);

const children = getByTestId('test');

expect(children.parentElement.className).toContain('wrapper');
});
});
196 changes: 69 additions & 127 deletions src/components/contextMenu/index.tsx
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,132 +1,74 @@
import React from 'react';
const contextPrefix = 'dtc-context-menu';

export interface ContextMenuProps {
key?: string;
targetClassName?: string;
onChange?: Function;
[propName: string]: any;
}
export interface ContextMenuItemProps {
key?: string;
onClick?: () => void;
value?: string;
[propName: string]: any;
import React, { CSSProperties, PropsWithChildren } from 'react';
import { Dropdown, Menu, DropdownProps, Popconfirm, PopconfirmProps } from 'antd';

interface IMenuProps {
/**
* @required
*/
key: React.Key;
/**
* 菜单栏的标题文案
*/
text: React.ReactNode;
/**
* 菜单栏的样式
*/
style?: CSSProperties;
/**
* 是否支持 popconfirm 的弹出
*/
confirm?: boolean;
/**
* 只有设置了 `confirm` 项的情况下,该属性才会生效
*/
confirmProps?: PopconfirmProps;
/**
* 菜单栏的点击事件
*/
cb?: () => void;
}

export class ContextMenuItem extends React.Component<ContextMenuItemProps, any> {
render() {
return (
<li {...this.props} className={`${contextPrefix}-context-list_li`}>
<a className={`${contextPrefix}-context-list_a`} data-value={this.props.value}>
{this.props.children}
</a>
</li>
);
}
interface IContextMenu
extends Pick<
DropdownProps,
'destroyPopupOnHide' | 'getPopupContainer' | 'placement' | 'overlayClassName'
> {
data: IMenuProps[];
wrapperClassName?: string;
}

export default class ContextMenu extends React.Component<ContextMenuProps, any> {
constructor(props: ContextMenuProps) {
super(props);
this.toggleMenu = this.toggleMenu.bind(this);
this.removeMenu = this.removeMenu.bind(this);
}
static ContextMenuItem = ContextMenuItem;

selfEle: HTMLElement;

componentDidMount() {
document.addEventListener('contextmenu', this.toggleMenu, false);
document.addEventListener('click', this.removeMenu, false);
}

componentWillUnmount() {
document.removeEventListener('click', this.removeMenu, false);
document.removeEventListener('contextmenu', this.toggleMenu, false);
}

toggleMenu(evt: MouseEvent) {
const { targetClassName, onChange } = this.props;
const selfEle = this.selfEle;
if (!selfEle) return;
const parent = this.findParent(evt.target as HTMLElement, targetClassName);

if (parent) {
this.hideAll();

const style = selfEle.style;
style.display = 'block';

const pointerY = evt.clientY;
const pointerX = evt.clientX;
const viewHeight = document.body.offsetHeight; // 可视区高度
const distanceToBottom = viewHeight - pointerY;
const menuHeight = selfEle.offsetHeight;
const menuTop = distanceToBottom > menuHeight ? pointerY : pointerY - menuHeight;

style.cssText = `
top: ${menuTop}px;
left: ${pointerX}px;
display: block;
`;
if (onChange) {
onChange(parent);
}
evt.preventDefault();
}
}

hideAll() {
const allEles: any = document.querySelectorAll(`.${contextPrefix}`);
for (let i = 0; i < allEles.length; i++) {
allEles[i].style.display = 'none';
}
}

closeMenu(_evt: MouseEvent) {
if (!this.selfEle) return;
const style = this.selfEle.style;
style.display = 'none';
}

removeMenu(_evt: MouseEvent) {
if (!this.selfEle) return;
const style = this.selfEle.style;
style.display = 'none';
}

findParent(child: HTMLElement, selector: string) {
let selectorTemp = selector;
try {
if (!selectorTemp || !child) return;
selectorTemp = selectorTemp.toLowerCase();
let node: any = child;
while (node) {
if (node.nodeType === 1) {
// just hand dom element
const className = node.getAttribute('class');
if (className && className.includes(selectorTemp)) return node;
}
node = node.parentNode;
}
} catch (e) {
throw new Error(e);
}
return null;
}

render() {
return (
<div
ref={(e) => {
this.selfEle = e;
}}
className={contextPrefix}
style={{ display: 'none' }}
>
<ul className={`${contextPrefix}-context-menu_list`}>{this.props.children}</ul>
</div>
);
}
export default function ContextMenu({
data = [],
children,
wrapperClassName,
...restProps
}: PropsWithChildren<IContextMenu>) {
const menu = (
<Menu
className="dt-contextMenu-menu"
onClick={(item) => data.find((i) => i.key === item.key)?.cb?.()}
>
{data.map((item) =>
item.confirm ? (
<Menu.Item style={item.style} key={item.key}>
<Popconfirm key={item.key} {...item.confirmProps}>
<div>{item.text}</div>
</Popconfirm>
</Menu.Item>
) : (
<Menu.Item style={item.style} key={item.key}>
{item.text}
</Menu.Item>
)
)}
</Menu>
);

if (!data.length) return <span className={wrapperClassName}>{children}</span>;

return (
<Dropdown overlay={menu} trigger={['contextMenu']} {...restProps}>
<span className={wrapperClassName}>{children}</span>
</Dropdown>
);
}
41 changes: 13 additions & 28 deletions src/components/contextMenu/style.scss
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,34 +1,19 @@
$context-prefix: "dtc-context-menu";
.#{$context-prefix} {
position: fixed;
min-width: 90px;
max-width: 160px;
display: none;
z-index: 9999;
.dt-contextMenu-menu {
padding: 0;
background: #FFF;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
border-radius: 2px;
.#{$context-prefix}-context-menu_list {
margin: 0;
padding: 0;
.#{$context-prefix}-context-list_li {
list-style-type: none;
}
.#{$context-prefix}-context-list_a {
display: block;
padding: 7px 8px;
margin: 0;
clear: both;
font-size: 12px;
font-weight: normal;
color: rgba(0, 0, 0, 0.65);
white-space: nowrap;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
&:hover {
background: #EEF6FE;
}
min-width: 90px;
max-width: 160px;
.ant-dropdown-menu-item {
color: rgba(0, 0, 0, 0.65);
font-size: 12px;
font-weight: normal;
white-space: nowrap;
text-decoration: none;
line-height: inherit;
&:hover {
background: #EEF6FE;
}
}
}
Loading

0 comments on commit dadfedc

Please sign in to comment.