Skip to content

Commit

Permalink
feat(tree-node-base): add context menu support
Browse files Browse the repository at this point in the history
  • Loading branch information
gabrielchl committed Oct 2, 2024
1 parent 22ecba8 commit e56e3f8
Show file tree
Hide file tree
Showing 19 changed files with 734 additions and 131 deletions.
9 changes: 9 additions & 0 deletions src/components/ContextMenu/ContextMenu.constants.ts
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 };
10 changes: 10 additions & 0 deletions src/components/ContextMenu/ContextMenu.stories.args.ts
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,
};
1 change: 1 addition & 0 deletions src/components/ContextMenu/ContextMenu.stories.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The `<ContextMenu />` component.
44 changes: 44 additions & 0 deletions src/components/ContextMenu/ContextMenu.stories.tsx
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 };
21 changes: 21 additions & 0 deletions src/components/ContextMenu/ContextMenu.style.scss
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);
}
}
}
95 changes: 95 additions & 0 deletions src/components/ContextMenu/ContextMenu.tsx
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);

Check failure on line 46 in src/components/ContextMenu/ContextMenu.tsx

View workflow job for this annotation

GitHub Actions / Test - static

Unexpected console statement
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;
43 changes: 43 additions & 0 deletions src/components/ContextMenu/ContextMenu.types.ts
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>;
}
153 changes: 153 additions & 0 deletions src/components/ContextMenu/ContextMenu.unit.test.tsx
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');
});
});
});
Loading

0 comments on commit e56e3f8

Please sign in to comment.