-
Notifications
You must be signed in to change notification settings - Fork 56
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(tree-node-base): add context menu support
- Loading branch information
1 parent
22ecba8
commit e56e3f8
Showing
19 changed files
with
734 additions
and
131 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
const CLASS_PREFIX = 'md-context-menu'; | ||
|
||
const DEFAULTS = {}; | ||
|
||
const STYLE = { | ||
wrapper: `${CLASS_PREFIX}-wrapper`, | ||
}; | ||
|
||
export { CLASS_PREFIX, DEFAULTS, STYLE }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { commonStyles } from '../../storybook/helper.stories.argtypes'; | ||
|
||
const contextMenuArgTypes = {}; | ||
|
||
export { contextMenuArgTypes }; | ||
|
||
export default { | ||
...commonStyles, | ||
...contextMenuArgTypes, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
The `<ContextMenu />` component. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import { DocumentationPage } from '../../storybook/helper.stories.docs'; | ||
import StyleDocs from '../../storybook/docs.stories.style.mdx'; | ||
|
||
import ContextMenu from './'; | ||
import argTypes from './ContextMenu.stories.args'; | ||
import Documentation from './ContextMenu.stories.docs.mdx'; | ||
import React, { useRef } from 'react'; | ||
|
||
export default { | ||
title: 'Momentum UI/ContextMenu', | ||
component: ContextMenu, | ||
parameters: { | ||
expanded: true, | ||
docs: { | ||
page: DocumentationPage(Documentation, StyleDocs), | ||
}, | ||
}, | ||
}; | ||
|
||
const Template = (args) => { | ||
const triggerRef = useRef(null); // Create a ref for the trigger component | ||
|
||
return ( | ||
<div> | ||
<button ref={triggerRef}>Right click on me</button> | ||
<ContextMenu triggerRef={triggerRef} {...args} /> | ||
</div> | ||
); | ||
}; | ||
|
||
const Example = Template.bind({}); | ||
|
||
Example.argTypes = { ...argTypes }; | ||
|
||
Example.args = { | ||
contextMenuActions: [ | ||
// eslint-disable-next-line no-console | ||
{ text: 'Action 1', action: () => console.log('Action 1') }, | ||
// eslint-disable-next-line no-console | ||
{ text: 'Action 2', action: () => console.log('Action 2') }, | ||
], | ||
}; | ||
|
||
export { Example }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
.md-context-menu-wrapper { | ||
max-width: 20rem; | ||
border-radius: 0.75rem; | ||
z-index: 9999; | ||
|
||
& button { | ||
font-size: 0.75rem; | ||
padding: 0.25rem 0.5rem; | ||
border-radius: 0.5rem; | ||
border: var(--md-globals-border-clear); | ||
text-align: left; | ||
width: 100%; | ||
background-color: var(--mds-color-theme-button-secondary-normal); | ||
color: var(--mds-color-theme-text-primary-normal); | ||
|
||
&:hover { | ||
background-color: var(--mds-color-theme-button-secondary-hover); | ||
color: var(--mds-color-theme-text-primary-normal); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
import React, { FC, useEffect, useRef, useState } from 'react'; | ||
import classnames from 'classnames'; | ||
|
||
import { STYLE } from './ContextMenu.constants'; | ||
import { ContextMenuState, Props } from './ContextMenu.types'; | ||
import './ContextMenu.style.scss'; | ||
import { useOverlay } from 'react-aria'; | ||
import ModalContainer from '../ModalContainer'; | ||
import ButtonSimple from '../ButtonSimple'; | ||
|
||
/** | ||
* The ContextMenu component. | ||
*/ | ||
const ContextMenu: FC<Props> = (props: Props) => { | ||
const { className, id = 'context-menu', style, contextMenuActions, triggerRef } = props; | ||
|
||
const [contextMenuState, setContextMenuState] = useState<ContextMenuState>({ | ||
isOpen: false, | ||
x: 0, | ||
y: 0, | ||
}); | ||
|
||
const toggleContextMenu = () => { | ||
setContextMenuState({ ...contextMenuState, isOpen: !contextMenuState.isOpen }); | ||
}; | ||
|
||
const overlayRef = useRef(); | ||
const { overlayProps } = useOverlay( | ||
{ | ||
onClose: () => toggleContextMenu(), | ||
shouldCloseOnBlur: true, | ||
isOpen: contextMenuState.isOpen, | ||
isDismissable: true, | ||
}, | ||
overlayRef | ||
); | ||
|
||
const handleOnContextMenu = (event: MouseEvent) => { | ||
event.preventDefault(); | ||
// Don't allow to open more context-menus at the same time | ||
if (document.getElementById(id)) { | ||
return; | ||
} | ||
|
||
const { pageX, pageY } = event; | ||
console.log(event, pageX); | ||
setContextMenuState({ x: pageX, y: pageY, isOpen: !contextMenuState.isOpen }); | ||
}; | ||
|
||
useEffect(() => { | ||
if (contextMenuActions) { | ||
triggerRef.current.addEventListener('contextmenu', handleOnContextMenu); | ||
} | ||
return () => { | ||
triggerRef.current?.removeEventListener('contextmenu', handleOnContextMenu); | ||
}; | ||
}, []); | ||
|
||
if (!contextMenuActions || !contextMenuState.isOpen) { | ||
return null; | ||
} | ||
|
||
return ( | ||
<ModalContainer | ||
isPadded | ||
round={75} | ||
className={classnames(className, STYLE.wrapper)} | ||
{...overlayProps} | ||
id={id} | ||
color={'primary' as const} | ||
style={{ | ||
position: 'fixed', | ||
left: `${contextMenuState.x}px`, | ||
top: `${contextMenuState.y}px`, | ||
...style, | ||
}} | ||
ref={overlayRef} | ||
> | ||
{contextMenuActions.map((item, index) => ( | ||
<ButtonSimple | ||
key={index} | ||
aria-label={item?.text} | ||
onPress={() => { | ||
toggleContextMenu(); | ||
item?.action(); | ||
}} | ||
> | ||
{item?.text} | ||
</ButtonSimple> | ||
))} | ||
</ModalContainer> | ||
); | ||
}; | ||
|
||
export default ContextMenu; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
import { CSSProperties, ReactNode, RefObject } from 'react'; | ||
|
||
export interface ContextMenuState { | ||
isOpen: boolean; | ||
x: number; | ||
y: number; | ||
} | ||
|
||
type ContextMenuAction = { | ||
text?: string; | ||
action?: () => void; | ||
}; | ||
|
||
export interface ContextMenuActionsProp { | ||
contextMenuActions?: ContextMenuAction[]; | ||
} | ||
|
||
export interface Props extends ContextMenuActionsProp { | ||
/** | ||
* Child components of this ContextMenu. | ||
*/ | ||
children?: ReactNode; | ||
|
||
/** | ||
* Custom class for overriding this component's CSS. | ||
*/ | ||
className?: string; | ||
|
||
/** | ||
* Custom id for overriding this component's CSS. | ||
*/ | ||
id?: string; | ||
|
||
/** | ||
* Custom style for overriding this component's CSS. | ||
*/ | ||
style?: CSSProperties; | ||
|
||
/** | ||
* Ref object of the context menu trigger. | ||
*/ | ||
triggerRef?: RefObject<HTMLElement>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
import React, { useRef } from 'react'; | ||
import { mount } from 'enzyme'; | ||
|
||
import ContextMenu, { CONTEXT_MENU_CONSTANTS as CONSTANTS } from './'; | ||
import { act } from 'react-dom/test-utils'; | ||
|
||
const defaultProps = { | ||
contextMenuActions: [ | ||
{ text: 'Action 1', action: () => null }, | ||
{ text: 'Action 2', action: () => null }, | ||
], | ||
}; | ||
|
||
const TestComponent = (props) => { | ||
const triggerRef = useRef(null); | ||
|
||
return ( | ||
<div> | ||
<button ref={triggerRef}>Right click on me</button> | ||
<ContextMenu triggerRef={triggerRef} {...props} /> | ||
</div> | ||
); | ||
}; | ||
|
||
const rightClickOnElement = (element) => { | ||
act(() => { | ||
// simulate('contextmenu') won't work when the event listener is added directly to the element instead of using props | ||
element.dispatchEvent(new MouseEvent('contextmenu')); | ||
}); | ||
}; | ||
|
||
const renderAndTriggerContextMenu = (props) => { | ||
const container = mount(<TestComponent {...props} />, { | ||
attachTo: document.getElementById('container'), | ||
}); | ||
rightClickOnElement(container.find('button').getDOMNode()); | ||
const contextMenu = document.getElementById(props.id || 'context-menu'); | ||
return { container, contextMenu }; | ||
}; | ||
|
||
describe('<ContextMenu />', () => { | ||
beforeEach(() => { | ||
document.body.innerHTML = '<div id="container"></div>'; | ||
}); | ||
|
||
afterEach(() => { | ||
const div = document.getElementById('container'); | ||
if (div) { | ||
document.body.removeChild(div); | ||
} | ||
}); | ||
|
||
// these snapshots will test both the container and contextMenu | ||
describe('snapshot', () => { | ||
it('should match snapshot', () => { | ||
expect.assertions(1); | ||
|
||
const container = renderAndTriggerContextMenu(defaultProps); | ||
|
||
expect(container).toMatchSnapshot(); | ||
}); | ||
|
||
it('should match snapshot with className', () => { | ||
expect.assertions(1); | ||
|
||
const className = 'example-class'; | ||
|
||
const container = renderAndTriggerContextMenu({ ...defaultProps, className }); | ||
|
||
expect(container).toMatchSnapshot(); | ||
}); | ||
|
||
it('should match snapshot with id', () => { | ||
expect.assertions(1); | ||
|
||
const id = 'example-id'; | ||
|
||
const container = renderAndTriggerContextMenu({ ...defaultProps, id }); | ||
|
||
expect(container).toMatchSnapshot(); | ||
}); | ||
|
||
it('should match snapshot with style', () => { | ||
expect.assertions(1); | ||
|
||
const style = { color: 'pink' }; | ||
|
||
const container = renderAndTriggerContextMenu({ ...defaultProps, style }); | ||
|
||
expect(container).toMatchSnapshot(); | ||
}); | ||
|
||
/* ...additional snapshot tests... */ | ||
}); | ||
|
||
describe('attributes', () => { | ||
it('should have its wrapper class', () => { | ||
expect.assertions(1); | ||
|
||
const { contextMenu } = renderAndTriggerContextMenu(defaultProps); | ||
|
||
expect(contextMenu.classList.contains(CONSTANTS.STYLE.wrapper)).toBe(true); | ||
}); | ||
|
||
it('should have provided class when className is provided', () => { | ||
expect.assertions(1); | ||
|
||
const className = 'example-class'; | ||
|
||
const { contextMenu } = renderAndTriggerContextMenu({ ...defaultProps, className }); | ||
|
||
expect(contextMenu.classList.contains(className)).toBe(true); | ||
}); | ||
|
||
it('should have provided id when id is provided', () => { | ||
expect.assertions(1); | ||
|
||
const id = 'example-id'; | ||
|
||
const { contextMenu } = renderAndTriggerContextMenu({ ...defaultProps, id }); | ||
|
||
expect(contextMenu.id).toBe(id); | ||
}); | ||
|
||
it('should have provided style when style is provided', () => { | ||
expect.assertions(1); | ||
|
||
const style = { color: 'pink' }; | ||
const styleString = 'position: fixed; color: pink;'; | ||
|
||
const { contextMenu } = renderAndTriggerContextMenu({ ...defaultProps, style }); | ||
|
||
expect(contextMenu.getAttribute('style')).toBe(styleString); | ||
}); | ||
}); | ||
|
||
describe('actions', () => { | ||
it('does not show context menu when contextMenuActions is not provided', () => { | ||
const { contextMenu } = renderAndTriggerContextMenu({}); | ||
expect(contextMenu).toBe(null); | ||
}); | ||
|
||
it('does shows context menu on right click', () => { | ||
const { contextMenu } = renderAndTriggerContextMenu(defaultProps); | ||
expect(contextMenu).toBeTruthy(); | ||
expect(contextMenu.children.length).toBe(2); | ||
expect(contextMenu.children[0].tagName).toBe('BUTTON'); | ||
expect(contextMenu.children[0].textContent).toBe('Action 1'); | ||
expect(contextMenu.children[1].tagName).toBe('BUTTON'); | ||
expect(contextMenu.children[1].textContent).toBe('Action 2'); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.