Skip to content

Commit 7e34025

Browse files
gagikkraenhansen
andauthored
feat(compass-components): add context menu COMPASS-9386 (#6956)
--------- Co-authored-by: Kræn Hansen <[email protected]>
1 parent 539c383 commit 7e34025

File tree

16 files changed

+576
-205
lines changed

16 files changed

+576
-205
lines changed

configs/eslint-config-compass/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ const tsxRules = {
4444
'react-hooks/exhaustive-deps': [
4545
'warn',
4646
{
47-
additionalHooks: 'useTrackOnChange',
47+
additionalHooks:
48+
'(useTrackOnChange|useContextMenuItems|useContextMenuGroups)',
4849
},
4950
],
5051
};

package-lock.json

Lines changed: 202 additions & 135 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/compass-components/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,12 @@
5252
"@leafygreen-ui/leafygreen-provider": "^4.0.2",
5353
"@leafygreen-ui/logo": "^10.0.2",
5454
"@leafygreen-ui/marketing-modal": "^5.0.2",
55-
"@leafygreen-ui/menu": "^28.0.2",
55+
"@leafygreen-ui/menu": "^29.0.5",
5656
"@leafygreen-ui/modal": "^17.0.2",
5757
"@leafygreen-ui/palette": "^4.1.3",
5858
"@leafygreen-ui/pipeline": "^7.0.2",
5959
"@leafygreen-ui/polymorphic": "^2.0.5",
60-
"@leafygreen-ui/popover": "^13.0.2",
60+
"@leafygreen-ui/popover": "^13.0.11",
6161
"@leafygreen-ui/portal": "^6.0.2",
6262
"@leafygreen-ui/radio-box-group": "^14.0.2",
6363
"@leafygreen-ui/radio-group": "^12.0.2",
@@ -73,8 +73,9 @@
7373
"@leafygreen-ui/toast": "^7.0.2",
7474
"@leafygreen-ui/toggle": "^11.0.2",
7575
"@leafygreen-ui/tokens": "^2.11.3",
76-
"@leafygreen-ui/tooltip": "^13.0.2",
76+
"@leafygreen-ui/tooltip": "^13.0.13",
7777
"@leafygreen-ui/typography": "^20.0.2",
78+
"@mongodb-js/compass-context-menu": "^0.0.1",
7879
"@react-aria/interactions": "^3.9.1",
7980
"@react-aria/utils": "^3.13.1",
8081
"@react-aria/visually-hidden": "^3.3.1",

packages/compass-components/src/components/compass-components-provider.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { GuideCueProvider } from './guide-cue/guide-cue';
66
import { SignalHooksProvider } from './signal-popover';
77
import { RequiredURLSearchParamsProvider } from './links/link';
88
import { StackedComponentProvider } from '../hooks/use-stacked-component';
9+
import { ContextMenuProvider } from './context-menu';
910

1011
type GuideCueProviderProps = React.ComponentProps<typeof GuideCueProvider>;
1112

@@ -135,15 +136,17 @@ export const CompassComponentsProvider = ({
135136
>
136137
<SignalHooksProvider {...signalHooksProviderProps}>
137138
<ConfirmationModalArea>
138-
<ToastArea>
139-
{typeof children === 'function'
140-
? children({
141-
darkMode,
142-
portalContainerRef: setPortalContainer,
143-
scrollContainerRef: setScrollContainer,
144-
})
145-
: children}
146-
</ToastArea>
139+
<ContextMenuProvider>
140+
<ToastArea>
141+
{typeof children === 'function'
142+
? children({
143+
darkMode,
144+
portalContainerRef: setPortalContainer,
145+
scrollContainerRef: setScrollContainer,
146+
})
147+
: children}
148+
</ToastArea>
149+
</ContextMenuProvider>
147150
</ConfirmationModalArea>
148151
</SignalHooksProvider>
149152
</GuideCueProvider>

packages/compass-components/src/components/content-with-fallback.spec.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ describe('ContentWithFallback', function () {
5858
{ container }
5959
);
6060

61-
expect(container).to.be.empty;
61+
expect(container.children.length).to.equal(1);
62+
const [anchorElement] = container.children;
63+
expect(anchorElement.getAttribute('data-testid')).to.equal('context-menu');
6264
});
6365

6466
it('should render fallback when the timeout passes', async function () {
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import React from 'react';
2+
import { render, screen, userEvent } from '@mongodb-js/testing-library-compass';
3+
import { expect } from 'chai';
4+
import sinon from 'sinon';
5+
import { ContextMenuProvider } from '@mongodb-js/compass-context-menu';
6+
import { useContextMenuItems, ContextMenu } from './context-menu';
7+
import type { ContextMenuItem } from '@mongodb-js/compass-context-menu';
8+
9+
describe('useContextMenuItems', function () {
10+
const menuTestTriggerId = 'test-trigger';
11+
12+
const TestComponent = ({
13+
items,
14+
children,
15+
'data-testid': dataTestId = menuTestTriggerId,
16+
}: {
17+
items: ContextMenuItem[];
18+
children?: React.ReactNode;
19+
'data-testid'?: string;
20+
}) => {
21+
const ref = useContextMenuItems(() => items, [items]);
22+
23+
return (
24+
<div data-testid={dataTestId} ref={ref}>
25+
Test Component
26+
{children}
27+
</div>
28+
);
29+
};
30+
31+
it('works with nested providers, using the parent provider', function () {
32+
const items = [
33+
{
34+
label: 'Test Item',
35+
onAction: () => {},
36+
},
37+
];
38+
39+
const { container } = render(
40+
<ContextMenuProvider menuWrapper={ContextMenu}>
41+
<ContextMenuProvider menuWrapper={ContextMenu}>
42+
<TestComponent items={items} />
43+
</ContextMenuProvider>
44+
</ContextMenuProvider>
45+
);
46+
47+
// Should only find one context menu (from the parent provider)
48+
expect(
49+
container.querySelectorAll('[data-testid="context-menu"]')
50+
).to.have.length(1);
51+
// Should still render the trigger
52+
expect(screen.getByTestId(menuTestTriggerId)).to.exist;
53+
});
54+
55+
it('renders without error', function () {
56+
const items = [
57+
{
58+
label: 'Test Item',
59+
onAction: () => {},
60+
},
61+
];
62+
63+
render(<TestComponent items={items} />);
64+
65+
expect(screen.getByTestId(menuTestTriggerId)).to.exist;
66+
});
67+
68+
it('shows context menu with items on right click', function () {
69+
const items = [
70+
{
71+
label: 'Test Item 1',
72+
onAction: () => {},
73+
},
74+
{
75+
label: 'Test Item 2',
76+
onAction: () => {},
77+
},
78+
];
79+
80+
render(<TestComponent items={items} />);
81+
82+
const trigger = screen.getByTestId(menuTestTriggerId);
83+
userEvent.click(trigger, { button: 2 });
84+
85+
// The menu items should be rendered
86+
expect(screen.getByTestId('menu-group-0-item-0')).to.exist;
87+
expect(screen.getByTestId('menu-group-0-item-1')).to.exist;
88+
});
89+
90+
it('triggers the correct action when menu item is clicked', function () {
91+
const onAction = sinon.spy();
92+
const items = [
93+
{
94+
label: 'Test Item 1',
95+
onAction: () => onAction(1),
96+
},
97+
{
98+
label: 'Test Item 2',
99+
onAction: () => onAction(2),
100+
},
101+
];
102+
103+
render(<TestComponent items={items} />);
104+
105+
const trigger = screen.getByTestId(menuTestTriggerId);
106+
userEvent.click(trigger, { button: 2 });
107+
108+
const menuItem = screen.getByTestId('menu-group-0-item-1');
109+
userEvent.click(menuItem);
110+
111+
expect(onAction).to.have.been.calledOnceWithExactly(2);
112+
});
113+
114+
describe('with nested components', function () {
115+
const childTriggerId = 'child-trigger';
116+
117+
beforeEach(function () {
118+
const items = [
119+
{
120+
label: 'Test Item 1',
121+
onAction: () => {},
122+
},
123+
{
124+
label: 'Test Item 2',
125+
onAction: () => {},
126+
},
127+
];
128+
129+
const childItems = [
130+
{
131+
label: 'Child Item 1',
132+
onAction: () => {},
133+
},
134+
];
135+
136+
render(
137+
<TestComponent items={items}>
138+
<TestComponent items={childItems} data-testid={childTriggerId} />
139+
</TestComponent>
140+
);
141+
});
142+
143+
it('renders menu items with separators', function () {
144+
const trigger = screen.getByTestId(childTriggerId);
145+
userEvent.click(trigger, { button: 2 });
146+
147+
// Should find the menu item and the separator
148+
expect(screen.getByTestId('menu-group-0').children.length).to.equal(2);
149+
expect(
150+
screen.getByTestId('menu-group-0').children.item(0)?.textContent
151+
).to.equal('Child Item 1');
152+
153+
expect(screen.getByTestId('menu-group-0-separator')).to.exist;
154+
155+
expect(screen.getByTestId('menu-group-1').children.length).to.equal(2);
156+
expect(
157+
screen.getByTestId('menu-group-1').children.item(0)?.textContent
158+
).to.equal('Test Item 1');
159+
expect(
160+
screen.getByTestId('menu-group-1').children.item(1)?.textContent
161+
).to.equal('Test Item 2');
162+
163+
expect(screen.queryByTestId('menu-group-1-separator')).not.to.exist;
164+
});
165+
});
166+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import React, { useEffect, useMemo, useRef } from 'react';
2+
import { Menu, MenuItem, MenuSeparator } from './leafygreen';
3+
4+
import {
5+
ContextMenuProvider as ContextMenuProviderBase,
6+
useContextMenu,
7+
type ContextMenuItem,
8+
type ContextMenuItemGroup,
9+
type ContextMenuWrapperProps,
10+
} from '@mongodb-js/compass-context-menu';
11+
12+
export type { ContextMenuItem } from '@mongodb-js/compass-context-menu';
13+
14+
export function ContextMenuProvider({
15+
children,
16+
}: {
17+
children: React.ReactNode;
18+
}) {
19+
return (
20+
<ContextMenuProviderBase menuWrapper={ContextMenu}>
21+
{children}
22+
</ContextMenuProviderBase>
23+
);
24+
}
25+
26+
export function ContextMenu({ menu }: ContextMenuWrapperProps) {
27+
const menuRef = useRef(null);
28+
29+
const { position, itemGroups } = menu;
30+
31+
useEffect(() => {
32+
if (!menu.isOpen) {
33+
menu.close();
34+
}
35+
}, [menu.isOpen]);
36+
37+
return (
38+
<div
39+
data-testid="context-menu"
40+
style={{
41+
position: 'absolute',
42+
left: position.x,
43+
top: position.y,
44+
// This is to ensure the menu gets positioned correctly as the left and top updates
45+
width: 1,
46+
height: 1,
47+
}}
48+
>
49+
<Menu
50+
ref={menuRef}
51+
open={menu.isOpen}
52+
setOpen={menu.close}
53+
justify="start"
54+
>
55+
{itemGroups.map((items: ContextMenuItemGroup, groupIndex: number) => {
56+
return (
57+
<div
58+
key={`menu-group-${groupIndex}`}
59+
data-testid={`menu-group-${groupIndex}`}
60+
>
61+
{items.map((item: ContextMenuItem, itemIndex: number) => {
62+
return (
63+
<MenuItem
64+
key={`menu-group-${groupIndex}-item-${itemIndex}`}
65+
data-text={item.label}
66+
data-testid={`menu-group-${groupIndex}-item-${itemIndex}`}
67+
onClick={(evt: React.MouseEvent) => {
68+
item.onAction?.(evt);
69+
menu.close();
70+
}}
71+
>
72+
{item.label}
73+
</MenuItem>
74+
);
75+
})}
76+
{groupIndex < itemGroups.length - 1 && (
77+
<div
78+
key={`menu-group-${groupIndex}-separator`}
79+
data-testid={`menu-group-${groupIndex}-separator`}
80+
>
81+
<MenuSeparator />
82+
</div>
83+
)}
84+
</div>
85+
);
86+
})}
87+
</Menu>
88+
</div>
89+
);
90+
}
91+
92+
export function useContextMenuItems(
93+
getItems: () => ContextMenuItem[],
94+
dependencies: React.DependencyList | undefined
95+
): React.RefCallback<HTMLElement> {
96+
// eslint-disable-next-line react-hooks/exhaustive-deps
97+
const memoizedItems = useMemo(getItems, dependencies);
98+
const contextMenu = useContextMenu();
99+
return contextMenu.registerItems(memoizedItems);
100+
}
101+
102+
export function useContextMenuGroups(
103+
getGroups: () => ContextMenuItemGroup[],
104+
dependencies: React.DependencyList | undefined
105+
): React.RefCallback<HTMLElement> {
106+
// eslint-disable-next-line react-hooks/exhaustive-deps
107+
const memoizedGroups = useMemo(getGroups, dependencies);
108+
const contextMenu = useContextMenu();
109+
return contextMenu.registerItems(...memoizedGroups);
110+
}

packages/compass-components/src/components/leafygreen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ import LeafyGreenTextInput from '@leafygreen-ui/text-input';
7272
import { SearchInput } from '@leafygreen-ui/search-input';
7373
export type { ToastProps } from '@leafygreen-ui/toast';
7474
export { ToastProvider, useToast } from '@leafygreen-ui/toast';
75-
export { usePrevious } from '@leafygreen-ui/hooks';
75+
export { usePrevious, useMergeRefs } from '@leafygreen-ui/hooks';
7676
import Toggle from '@leafygreen-ui/toggle';
7777
import Tooltip from '@leafygreen-ui/tooltip';
7878
import {

packages/compass-components/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ export { ModalHeader } from './components/modals/modal-header';
100100
export { FormModal } from './components/modals/form-modal';
101101
export { InfoModal } from './components/modals/info-modal';
102102

103+
export {
104+
useContextMenuItems,
105+
useContextMenuGroups,
106+
type ContextMenuItem,
107+
} from './components/context-menu';
108+
103109
export type {
104110
FileInputBackend,
105111
ItemAction,

packages/compass-context-menu/src/context-menu-content.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ export function getContextMenuContent(
1414

1515
export function appendContextMenuContent(
1616
event: EnhancedMouseEvent,
17-
content: ContextMenuItemGroup
17+
...groups: ContextMenuItemGroup[]
1818
) {
1919
// Initialize if not already patched
2020
if (!event[CONTEXT_MENUS_SYMBOL]) {
2121
event[CONTEXT_MENUS_SYMBOL] = [];
2222
}
23-
event[CONTEXT_MENUS_SYMBOL].push(content);
23+
event[CONTEXT_MENUS_SYMBOL].push(...groups);
2424
}

0 commit comments

Comments
 (0)