Skip to content

feat(compass-components): add context menu COMPASS-9386 #6956

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 51 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
297a943
feat(compass-context-menu): add a headless context menu package
gagik May 20, 2025
6838513
wip
gagik May 21, 2025
8e30a83
fix: add tests
gagik May 21, 2025
7d17984
fix: add tests and fix types
gagik May 21, 2025
6004593
refactor: minor stylistic changes
gagik May 21, 2025
43023f4
fix: export types and rename MenuItem
gagik May 22, 2025
971b0fd
fix: basic UI implementation
gagik May 22, 2025
0ad7a63
fix: use React.RefCallback
gagik May 22, 2025
aa9f0cf
fix: switch to item-based organization
gagik May 23, 2025
58df56a
fix: cleanup and switch to menu prop
gagik May 23, 2025
a54c738
refactor: use item groups instead of React elements, use wrapper, kee…
gagik May 23, 2025
731ac13
Merge branch 'gagik/headless-context-menu' of github.com:mongodb-js/c…
gagik May 23, 2025
56d11b6
fix: revert test environment
gagik May 23, 2025
743f068
fix: justify start to make it prefer right way popups and remove comment
gagik May 23, 2025
ab14b78
fix: remove redundant context import
gagik May 23, 2025
1d279ca
feat(compass-context-menu): add a headless context menu package
gagik May 20, 2025
1efdb2e
wip
gagik May 21, 2025
0f5303b
fix: add tests
gagik May 21, 2025
33b2a41
fix: add tests and fix types
gagik May 21, 2025
4a2f032
refactor: minor stylistic changes
gagik May 21, 2025
8e1feb3
fix: export types and rename MenuItem
gagik May 22, 2025
1f55000
fix: use React.RefCallback
gagik May 22, 2025
9618e8e
refactor: use item groups instead of React elements, use wrapper, kee…
gagik May 23, 2025
ca1fb86
fix: delete redundant context menu
gagik May 23, 2025
a285d63
Merge branch 'gagik/headless-context-menu' of github.com:mongodb-js/c…
gagik May 23, 2025
aacdf1d
feat(compass-context-menu): add a headless context menu package
gagik May 20, 2025
cb99706
wip
gagik May 21, 2025
da22b51
fix: add tests
gagik May 21, 2025
78ff94c
fix: add tests and fix types
gagik May 21, 2025
a24e89c
refactor: minor stylistic changes
gagik May 21, 2025
f3869ea
fix: export types and rename MenuItem
gagik May 22, 2025
c2d9ac1
fix: use React.RefCallback
gagik May 22, 2025
1c6ef03
refactor: use item groups instead of React elements, use wrapper, kee…
gagik May 23, 2025
3d14d6d
fix: delete redundant context menu
gagik May 23, 2025
ee658e1
fix: remove unused dep
gagik Jun 2, 2025
a173aae
Merge branch 'gagik/headless-context-menu' of github.com:mongodb-js/c…
gagik Jun 2, 2025
4730c18
fix: add tests and fix bug with menu auto-closing
gagik Jun 10, 2025
eb65769
fix: enforce no nesting, adjsut enzyme test and move setup to testing…
gagik Jun 10, 2025
220a300
fix: move to compass components provider
gagik Jun 10, 2025
74f3d6e
fix: remove unintended deletion
gagik Jun 10, 2025
4f05381
fix: adjust tests
gagik Jun 10, 2025
4ad7231
Merge branch 'main' of github.com:mongodb-js/compass into gagik/conte…
gagik Jun 18, 2025
cbb0656
Merge branch 'main' of github.com:mongodb-js/compass into gagik/headl…
gagik Jun 18, 2025
c6d7b03
Merge branch 'gagik/headless-context-menu' of github.com:mongodb-js/c…
gagik Jun 18, 2025
6b6e398
fix: throw early on
gagik Jun 18, 2025
17c05f0
Merge branch 'main' of github.com:mongodb-js/compass into gagik/conte…
gagik Jun 19, 2025
6dadf25
fix: correct wrapper use
gagik Jun 19, 2025
a62690b
Merge branch 'main' of github.com:mongodb-js/compass into gagik/conte…
gagik Jun 19, 2025
4dafa7b
fix: use render directly for compass-context-menu
gagik Jun 19, 2025
915c597
fix: use testingLibrary's render
gagik Jun 19, 2025
8f306e4
feat: memoize items for context menu
gagik Jun 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion configs/eslint-config-compass/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const tsxRules = {
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: 'useTrackOnChange',
additionalHooks: '(useTrackOnChange|useContextMenuItems)',
},
],
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { GuideCueProvider } from './guide-cue/guide-cue';
import { SignalHooksProvider } from './signal-popover';
import { RequiredURLSearchParamsProvider } from './links/link';
import { StackedComponentProvider } from '../hooks/use-stacked-component';
import { ContextMenuProvider } from './context-menu';

type GuideCueProviderProps = React.ComponentProps<typeof GuideCueProvider>;

Expand Down Expand Up @@ -135,15 +136,17 @@ export const CompassComponentsProvider = ({
>
<SignalHooksProvider {...signalHooksProviderProps}>
<ConfirmationModalArea>
<ToastArea>
{typeof children === 'function'
? children({
darkMode,
portalContainerRef: setPortalContainer,
scrollContainerRef: setScrollContainer,
})
: children}
</ToastArea>
<ContextMenuProvider>
<ToastArea>
{typeof children === 'function'
? children({
darkMode,
portalContainerRef: setPortalContainer,
scrollContainerRef: setScrollContainer,
})
: children}
</ToastArea>
</ContextMenuProvider>
</ConfirmationModalArea>
</SignalHooksProvider>
</GuideCueProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ describe('ContentWithFallback', function () {
{ container }
);

expect(container).to.be.empty;
expect(container.children.length).to.equal(1);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one might want to keep this empty... the reason the context menu always exists even when closed is for the sake of animations

expect(container.children[0].getAttribute('data-testid')).to.equal(
'context-menu'
);
});

it('should render fallback when the timeout passes', async function () {
Expand Down
161 changes: 161 additions & 0 deletions packages/compass-components/src/components/context-menu.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React from 'react';
import { render, screen, userEvent } from '@mongodb-js/testing-library-compass';
import { expect } from 'chai';
import sinon from 'sinon';
import { ContextMenuProvider } from '@mongodb-js/compass-context-menu';
import { useContextMenuItems, ContextMenu } from './context-menu';
import type { ContextMenuItem } from '@mongodb-js/compass-context-menu';

describe('useContextMenuItems', function () {
const menuTestTriggerId = 'test-trigger';

const TestComponent = ({
items,
children,
'data-testid': dataTestId = menuTestTriggerId,
}: {
items: ContextMenuItem[];
children?: React.ReactNode;
'data-testid'?: string;
}) => {
const ref = useContextMenuItems(() => items, [items]);

return (
<div data-testid={dataTestId} ref={ref}>
Test Component
{children}
</div>
);
};

it('errors if the component is double wrapped', function () {
const items = [
{
label: 'Test Item',
onAction: () => {},
},
];

expect(() => {
render(
<ContextMenuProvider menuWrapper={ContextMenu}>
<TestComponent items={items} />
</ContextMenuProvider>
);
}).to.throw(
'Duplicated ContextMenuProvider found. Please remove the nested provider.'
);
});

it('renders without error', function () {
const items = [
{
label: 'Test Item',
onAction: () => {},
},
];

render(<TestComponent items={items} />);

expect(screen.getByTestId(menuTestTriggerId)).to.exist;
});

it('shows context menu with items on right click', function () {
const items = [
{
label: 'Test Item 1',
onAction: () => {},
},
{
label: 'Test Item 2',
onAction: () => {},
},
];

render(<TestComponent items={items} />);

const trigger = screen.getByTestId(menuTestTriggerId);
userEvent.click(trigger, { button: 2 });

// The menu items should be rendered
expect(screen.getByTestId('menu-group-0-item-0')).to.exist;
expect(screen.getByTestId('menu-group-0-item-1')).to.exist;
});

it('triggers the correct action when menu item is clicked', function () {
const onAction = sinon.spy();
const items = [
{
label: 'Test Item 1',
onAction: () => onAction(1),
},
{
label: 'Test Item 2',
onAction: () => onAction(2),
},
];

render(<TestComponent items={items} />);

const trigger = screen.getByTestId(menuTestTriggerId);
userEvent.click(trigger, { button: 2 });

const menuItem = screen.getByTestId('menu-group-0-item-1');
userEvent.click(menuItem);

expect(onAction).to.have.been.calledOnceWithExactly(2);
});

describe('with nested components', function () {
const childTriggerId = 'child-trigger';

beforeEach(function () {
const items = [
{
label: 'Test Item 1',
onAction: () => {},
},
{
label: 'Test Item 2',
onAction: () => {},
},
];

const childItems = [
{
label: 'Child Item 1',
onAction: () => {},
},
];

render(
<TestComponent items={items}>
<TestComponent items={childItems} data-testid={childTriggerId} />
</TestComponent>
);
});

it('renders menu items with separators', function () {
const trigger = screen.getByTestId(childTriggerId);
userEvent.click(trigger, { button: 2 });

// Should find the menu item and the separator
expect(screen.getByTestId('menu-group-0').children.length).to.equal(2);
expect(
screen.getByTestId('menu-group-0').children.item(0)?.textContent
).to.equal('Child Item 1');

expect(screen.getByTestId('menu-group-0-separator')).to.exist;

expect(screen.getByTestId('menu-group-1').children.length).to.equal(2);
expect(
screen.getByTestId('menu-group-1').children.item(0)?.textContent
).to.equal('Test Item 1');
expect(
screen.getByTestId('menu-group-1').children.item(1)?.textContent
).to.equal('Test Item 2');

expect(screen.queryByTestId('menu-group-1-separator')).not.to.exist;
});
});
});
97 changes: 97 additions & 0 deletions packages/compass-components/src/components/context-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React, { useEffect, useMemo } from 'react';
import { Menu, MenuItem, MenuSeparator } from './leafygreen';
import type { ContextMenuItem } from '@mongodb-js/compass-context-menu';
import { useContextMenu } from '@mongodb-js/compass-context-menu';
import { ContextMenuProvider as ContextMenuProviderBase } from '@mongodb-js/compass-context-menu';
import type {
ContextMenuItemGroup,
ContextMenuWrapperProps,
} from '@mongodb-js/compass-context-menu';

export function ContextMenuProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<ContextMenuProviderBase menuWrapper={ContextMenu}>
{children}
</ContextMenuProviderBase>
);
}

export function ContextMenu({ menu }: ContextMenuWrapperProps) {
const position = menu.position;
const itemGroups = menu.itemGroups;

useEffect(() => {
if (!menu.isOpen) {
menu.close();
}
}, [menu.isOpen]);

return (
<div
data-testid="context-menu"
style={{
position: 'absolute',
pointerEvents: 'all',
left: position.x,
top: position.y,
}}
>
<Menu
renderMode="inline"
open={menu.isOpen}
setOpen={menu.close}
justify="start"
>
{itemGroups.map(
(itemGroup: ContextMenuItemGroup, groupIndex: number) => {
return (
<div
key={`menu-group-${groupIndex}`}
data-testid={`menu-group-${groupIndex}`}
>
{itemGroup.items.map(
(item: ContextMenuItem, itemIndex: number) => {
return (
<MenuItem
key={`menu-group-${groupIndex}-item-${itemIndex}`}
data-text={item.label}
data-testid={`menu-group-${groupIndex}-item-${itemIndex}`}
onClick={(evt: React.MouseEvent) => {
item.onAction?.(evt);
menu.close();
}}
>
{item.label}
</MenuItem>
);
}
)}
{groupIndex < itemGroups.length - 1 && (
<div
key={`menu-group-${groupIndex}-separator`}
data-testid={`menu-group-${groupIndex}-separator`}
>
<MenuSeparator />
</div>
)}
</div>
);
}
)}
</Menu>
</div>
);
}

export function useContextMenuItems(
getItems: () => ContextMenuItem[],
dependencies: React.DependencyList | undefined
): React.RefCallback<HTMLElement> {
const memoizedItems = useMemo(getItems, dependencies);
const contextMenu = useContextMenu();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there could be reasons for us to memoize these items? maybe we should support that at this point

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternatively we leave that to outside of this hook

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't memoize these items it looks like it will recreate all of the listeners and the ref function every time it renders. I'm not sure how new functions passed to ref perform work or cause other items to render, but it sounds like its something that would be nice to avoid by memoizing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point. made it memoizable by default

return contextMenu.registerItems(memoizedItems);
}
2 changes: 2 additions & 0 deletions packages/compass-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export { ModalHeader } from './components/modals/modal-header';
export { FormModal } from './components/modals/form-modal';
export { InfoModal } from './components/modals/info-modal';

export { useContextMenuItems } from './components/context-menu';

export type {
FileInputBackend,
ItemAction,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import React from 'react';
import { render } from '@mongodb-js/testing-library-compass';
import { testingLibrary } from '@mongodb-js/testing-library-compass';
import { expect } from 'chai';
import { ContextMenuProvider } from './context-menu-provider';
import type { ContextMenuWrapperProps } from './types';

// We need to import from testing-library-compass directly to avoid the extra wrapping.
const { render } = testingLibrary;

describe('ContextMenuProvider', function () {
const TestMenu: React.FC<ContextMenuWrapperProps> = () => (
<div data-testid="test-menu">Test Menu</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import React from 'react';
import { render, screen, userEvent } from '@mongodb-js/testing-library-compass';
import {
screen,
userEvent,
testingLibrary,
} from '@mongodb-js/testing-library-compass';
import { expect } from 'chai';
import sinon from 'sinon';
import { useContextMenu } from './use-context-menu';
import { ContextMenuProvider } from './context-menu-provider';
import type { ContextMenuItem, ContextMenuWrapperProps } from './types';

// We need to import from testing-library-compass directly to avoid the extra wrapping.
const { render } = testingLibrary;

describe('useContextMenu', function () {
const TestMenu: React.FC<ContextMenuWrapperProps> = ({ menu }) => (
<div data-testid="test-menu">
Expand Down
Loading
Loading