Skip to content

Commit a54c738

Browse files
committed
refactor: use item groups instead of React elements, use wrapper, keep menu state consistent
The refactor is meant to make the Leafygreen integration more straightforward: 1. We stick to item groups and instead have a single wrapper to handle any rendering differences between groups. This allows the wrapper to always have context of all items when rendering which is useful when inserting menu seperators in Leafygreen. Also encourages consistent UI (while allowing per-case customization if needed at wrapper-level). We could introduce itemWrappers instead of itemGroups but having one wrapper handling all seems cleaner to me. 2. More of the responsibility is moved to a parent wrapper component that will house the context menu. This allows us to standardize the right click menu and make better use of Leafygreen's menu component including its click handling (which has been removed from the context menu library). 3. Menu state (i.e. position) is now preserved even closed; this is useful for leafygreen's menu to animate in the same position instead of losing the position all together.
1 parent 0ad7a63 commit a54c738

File tree

6 files changed

+124
-102
lines changed

6 files changed

+124
-102
lines changed
Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1+
import type { ContextMenuItemGroup } from './types';
2+
13
const CONTEXT_MENUS_SYMBOL = Symbol('context_menus');
24

35
export type EnhancedMouseEvent = MouseEvent & {
4-
[CONTEXT_MENUS_SYMBOL]?: React.ComponentType[];
6+
[CONTEXT_MENUS_SYMBOL]?: ContextMenuItemGroup[];
57
};
68

79
export function getContextMenuContent(
810
event: EnhancedMouseEvent
9-
): React.ComponentType[] {
11+
): ContextMenuItemGroup[] {
1012
return event[CONTEXT_MENUS_SYMBOL] ?? [];
1113
}
1214

1315
export function appendContextMenuContent(
1416
event: EnhancedMouseEvent,
15-
content: React.ComponentType
17+
content: ContextMenuItemGroup
1618
) {
1719
// Initialize if not already patched
18-
if (event[CONTEXT_MENUS_SYMBOL] === undefined) {
19-
event[CONTEXT_MENUS_SYMBOL] = [content];
20-
return;
20+
if (!event[CONTEXT_MENUS_SYMBOL]) {
21+
event[CONTEXT_MENUS_SYMBOL] = [];
2122
}
2223
event[CONTEXT_MENUS_SYMBOL].push(content);
2324
}

packages/compass-context-menu/src/context-menu-provider.tsx

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,50 @@ import React, {
55
useMemo,
66
createContext,
77
} from 'react';
8-
import type { ContextMenuContext, MenuState } from './types';
9-
import { ContextMenu } from './context-menu';
8+
import type { ContextMenuContext, ContextMenuState } from './types';
109
import type { EnhancedMouseEvent } from './context-menu-content';
1110
import { getContextMenuContent } from './context-menu-content';
1211

1312
export const Context = createContext<ContextMenuContext | null>(null);
1413

1514
export function ContextMenuProvider({
1615
children,
16+
wrapper,
1717
}: {
1818
children: React.ReactNode;
19+
wrapper: React.ComponentType<{
20+
menu: ContextMenuState & { close: () => void };
21+
}>;
1922
}) {
20-
const [menu, setMenu] = useState<MenuState>({ isOpen: false });
21-
const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]);
23+
const [menu, setMenu] = useState<ContextMenuState>({
24+
isOpen: false,
25+
itemGroups: [],
26+
position: { x: 0, y: 0 },
27+
});
28+
const close = useCallback(() => setMenu({ ...menu, isOpen: false }), [menu]);
29+
30+
const handleClosingEvent = useCallback(
31+
(event: Event) => {
32+
if (!event.defaultPrevented) {
33+
setMenu({ ...menu, isOpen: false });
34+
}
35+
},
36+
[menu]
37+
);
2238

2339
useEffect(() => {
2440
function handleContextMenu(event: MouseEvent) {
2541
event.preventDefault();
42+
43+
const itemGroups = getContextMenuContent(event as EnhancedMouseEvent);
44+
45+
if (itemGroups.length === 0) {
46+
return;
47+
}
48+
2649
setMenu({
2750
isOpen: true,
28-
children: getContextMenuContent(event as EnhancedMouseEvent).map(
29-
(Content, index) => <Content key={`menu-content-${index}`} />
30-
),
51+
itemGroups,
3152
position: {
3253
// TODO: Fix handling offset while scrolling
3354
x: event.clientX,
@@ -36,22 +57,14 @@ export function ContextMenuProvider({
3657
});
3758
}
3859

39-
function handleClosingEvent(event: Event) {
40-
if (!event.defaultPrevented) {
41-
setMenu({ isOpen: false });
42-
}
43-
}
44-
4560
document.addEventListener('contextmenu', handleContextMenu);
46-
document.addEventListener('click', handleClosingEvent);
4761
window.addEventListener('resize', handleClosingEvent);
4862

4963
return () => {
5064
document.removeEventListener('contextmenu', handleContextMenu);
51-
document.removeEventListener('click', handleClosingEvent);
5265
window.removeEventListener('resize', handleClosingEvent);
5366
};
54-
}, [setMenu]);
67+
}, [handleClosingEvent]);
5568

5669
const value = useMemo(
5770
() => ({
@@ -60,12 +73,12 @@ export function ContextMenuProvider({
6073
[close]
6174
);
6275

76+
const Wrapper = wrapper ?? React.Fragment;
77+
6378
return (
64-
<>
65-
<Context.Provider value={value}>{children}</Context.Provider>
66-
{menu.isOpen && (
67-
<ContextMenu position={menu.position}>{menu.children}</ContextMenu>
68-
)}
69-
</>
79+
<Context.Provider value={value}>
80+
{children}
81+
<Wrapper menu={{ ...menu, close }} />
82+
</Context.Provider>
7083
);
7184
}
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
export { useContextMenu } from './use-context-menu';
2-
export type { ContextMenuItem } from './types';
2+
export { ContextMenuProvider } from './context-menu-provider';
3+
export type {
4+
ContextMenuItem,
5+
ContextMenuItemGroup,
6+
ContextMenuWrapperProps,
7+
} from './types';

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
export type MenuState =
2-
| {
3-
isOpen: false;
4-
}
5-
| {
6-
isOpen: true;
7-
children: React.ReactNode;
8-
position: {
9-
x: number;
10-
y: number;
11-
};
12-
};
1+
export interface ContextMenuItemGroup {
2+
items: ContextMenuItem[];
3+
originListener: (event: MouseEvent) => void;
4+
}
5+
6+
export type ContextMenuState = {
7+
isOpen: boolean;
8+
itemGroups: ContextMenuItemGroup[];
9+
position: {
10+
x: number;
11+
y: number;
12+
};
13+
};
14+
15+
export type ContextMenuWrapperProps = {
16+
menu: ContextMenuState & { close: () => void };
17+
};
1318

1419
export type ContextMenuContext = {
1520
close(): void;

packages/compass-context-menu/src/use-context-menu.spec.tsx

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,29 @@ import { expect } from 'chai';
44
import sinon from 'sinon';
55
import { useContextMenu } from './use-context-menu';
66
import { ContextMenuProvider } from './context-menu-provider';
7-
import type { ContextMenuItem } from './types';
7+
import type { ContextMenuItem, ContextMenuWrapperProps } from './types';
88

99
describe('useContextMenu', function () {
10-
const TestMenu: React.FC<{ items: ContextMenuItem[] }> = ({ items }) => (
10+
const TestMenu: React.FC<ContextMenuWrapperProps> = ({ menu }) => (
1111
<div data-testid="test-menu">
12-
{items.map((item, idx) => (
13-
<div
14-
key={idx}
15-
data-testid={`menu-item-${item.label}`}
16-
role="menuitem"
17-
tabIndex={0}
18-
onClick={(event) => item.onAction?.(event)}
19-
onKeyDown={(event) => {
20-
if (event.key === 'Enter') {
21-
item.onAction?.(event);
22-
}
23-
}}
24-
>
25-
{item.label}
26-
</div>
27-
))}
12+
{menu.itemGroups.flatMap((group, groupIdx) =>
13+
group.items.map((item, idx) => (
14+
<div
15+
key={`${groupIdx}-${idx}`}
16+
data-testid={`menu-item-${item.label}`}
17+
role="menuitem"
18+
tabIndex={0}
19+
onClick={(event) => item.onAction?.(event)}
20+
onKeyDown={(event) => {
21+
if (event.key === 'Enter') {
22+
item.onAction?.(event);
23+
}
24+
}}
25+
>
26+
{item.label}
27+
</div>
28+
))
29+
)}
2830
</div>
2931
);
3032

@@ -33,9 +35,9 @@ describe('useContextMenu', function () {
3335
onAction,
3436
}: {
3537
onRegister?: (ref: any) => void;
36-
onAction?: (id) => void;
38+
onAction?: (id: number) => void;
3739
}) => {
38-
const contextMenu = useContextMenu({ Menu: TestMenu });
40+
const contextMenu = useContextMenu();
3941
const items: ContextMenuItem[] = [
4042
{
4143
label: 'Test Item',
@@ -55,15 +57,14 @@ describe('useContextMenu', function () {
5557
);
5658
};
5759

58-
// Add new test components for nested context menu scenario
5960
const ParentComponent = ({
6061
onAction,
6162
children,
6263
}: {
6364
onAction?: (id: number) => void;
6465
children?: React.ReactNode;
6566
}) => {
66-
const contextMenu = useContextMenu({ Menu: TestMenu });
67+
const contextMenu = useContextMenu();
6768
const parentItems: ContextMenuItem[] = [
6869
{
6970
label: 'Parent Item 1',
@@ -89,7 +90,7 @@ describe('useContextMenu', function () {
8990
}: {
9091
onAction?: (id: number) => void;
9192
}) => {
92-
const contextMenu = useContextMenu({ Menu: TestMenu });
93+
const contextMenu = useContextMenu();
9394
const childItems: ContextMenuItem[] = [
9495
{
9596
label: 'Child Item 1',
@@ -135,7 +136,7 @@ describe('useContextMenu', function () {
135136

136137
it('renders without error', function () {
137138
render(
138-
<ContextMenuProvider>
139+
<ContextMenuProvider wrapper={TestMenu}>
139140
<TestComponent />
140141
</ContextMenuProvider>
141142
);
@@ -147,7 +148,7 @@ describe('useContextMenu', function () {
147148
const onRegister = sinon.spy();
148149

149150
render(
150-
<ContextMenuProvider>
151+
<ContextMenuProvider wrapper={TestMenu}>
151152
<TestComponent onRegister={onRegister} />
152153
</ContextMenuProvider>
153154
);
@@ -158,7 +159,7 @@ describe('useContextMenu', function () {
158159

159160
it('shows context menu on right click', function () {
160161
render(
161-
<ContextMenuProvider>
162+
<ContextMenuProvider wrapper={TestMenu}>
162163
<TestComponent />
163164
</ContextMenuProvider>
164165
);
@@ -173,7 +174,7 @@ describe('useContextMenu', function () {
173174
describe('with nested context menus', function () {
174175
it('shows only parent items when right clicking parent area', function () {
175176
render(
176-
<ContextMenuProvider>
177+
<ContextMenuProvider wrapper={TestMenu}>
177178
<ParentComponent />
178179
</ContextMenuProvider>
179180
);
@@ -192,7 +193,7 @@ describe('useContextMenu', function () {
192193

193194
it('shows both parent and child items when right clicking child area', function () {
194195
render(
195-
<ContextMenuProvider>
196+
<ContextMenuProvider wrapper={TestMenu}>
196197
<ParentComponent>
197198
<ChildComponent />
198199
</ParentComponent>
@@ -214,7 +215,7 @@ describe('useContextMenu', function () {
214215
const childOnAction = sinon.spy();
215216

216217
render(
217-
<ContextMenuProvider>
218+
<ContextMenuProvider wrapper={TestMenu}>
218219
<ParentComponent onAction={parentOnAction}>
219220
<ChildComponent onAction={childOnAction} />
220221
</ParentComponent>
@@ -237,7 +238,7 @@ describe('useContextMenu', function () {
237238
const childOnAction = sinon.spy();
238239

239240
render(
240-
<ContextMenuProvider>
241+
<ContextMenuProvider wrapper={TestMenu}>
241242
<ParentComponent onAction={parentOnAction}>
242243
<ChildComponent onAction={childOnAction} />
243244
</ParentComponent>

0 commit comments

Comments
 (0)