Skip to content

Commit cb9d596

Browse files
authored
feat(compass-context-menu): add a headless context menu package COMPASS-9386 (#6937)
1 parent 19578f2 commit cb9d596

14 files changed

+674
-0
lines changed

package-lock.json

Lines changed: 104 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
ignores:
2+
- '@mongodb-js/prettier-config-compass'
3+
- '@mongodb-js/tsconfig-compass'
4+
- '@types/chai'
5+
- '@types/sinon-chai'
6+
- 'sinon'
7+
ignore-patterns:
8+
- 'dist'
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.nyc-output
2+
dist
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use strict';
2+
module.exports = {
3+
root: true,
4+
extends: ['@mongodb-js/eslint-config-compass'],
5+
parserOptions: {
6+
tsconfigRootDir: __dirname,
7+
project: ['./tsconfig-lint.json'],
8+
},
9+
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
'use strict';
2+
module.exports = require('@mongodb-js/mocha-config-compass/react');
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
{
2+
"name": "@mongodb-js/compass-context-menu",
3+
"author": {
4+
"name": "MongoDB Inc",
5+
"email": "[email protected]"
6+
},
7+
"publishConfig": {
8+
"access": "public"
9+
},
10+
"bugs": {
11+
"url": "https://jira.mongodb.org/projects/COMPASS/issues",
12+
"email": "[email protected]"
13+
},
14+
"homepage": "https://github.com/mongodb-js/compass",
15+
"version": "0.0.1",
16+
"repository": {
17+
"type": "git",
18+
"url": "https://github.com/mongodb-js/compass.git"
19+
},
20+
"files": [
21+
"dist"
22+
],
23+
"license": "SSPL",
24+
"main": "dist/index.js",
25+
"compass:main": "src/index.ts",
26+
"exports": {
27+
"import": "./dist/.esm-wrapper.mjs",
28+
"require": "./dist/index.js"
29+
},
30+
"compass:exports": {
31+
".": "./src/index.ts"
32+
},
33+
"types": "./dist/index.d.ts",
34+
"scripts": {
35+
"bootstrap": "npm run compile",
36+
"prepublishOnly": "npm run compile && compass-scripts check-exports-exist",
37+
"compile": "tsc -p tsconfig.json && gen-esm-wrapper . ./dist/.esm-wrapper.mjs",
38+
"typecheck": "tsc -p tsconfig-lint.json --noEmit",
39+
"eslint": "eslint-compass",
40+
"prettier": "prettier-compass",
41+
"lint": "npm run eslint . && npm run prettier -- --check .",
42+
"depcheck": "compass-scripts check-peer-deps && depcheck",
43+
"check": "npm run typecheck && npm run lint && npm run depcheck",
44+
"check-ci": "npm run check",
45+
"test": "mocha",
46+
"test-cov": "nyc --compact=false --produce-source-map=false -x \"**/*.spec.*\" --reporter=lcov --reporter=text --reporter=html npm run test",
47+
"test-watch": "npm run test -- --watch",
48+
"test-ci": "npm run test-cov",
49+
"reformat": "npm run eslint . -- --fix && npm run prettier -- --write ."
50+
},
51+
"dependencies": {
52+
"react": "^17.0.2"
53+
},
54+
"devDependencies": {
55+
"@mongodb-js/eslint-config-compass": "^1.3.8",
56+
"@mongodb-js/mocha-config-compass": "^1.6.8",
57+
"@mongodb-js/prettier-config-compass": "^1.2.8",
58+
"@mongodb-js/testing-library-compass": "^1.3.1",
59+
"@mongodb-js/tsconfig-compass": "^1.2.8",
60+
"@types/chai": "^4.2.21",
61+
"@types/mocha": "^9.0.0",
62+
"@types/react": "^17.0.5",
63+
"@types/sinon-chai": "^3.2.5",
64+
"chai": "^4.3.6",
65+
"depcheck": "^1.4.1",
66+
"gen-esm-wrapper": "^1.1.0",
67+
"mocha": "^10.2.0",
68+
"nyc": "^15.1.0",
69+
"sinon": "^9.2.3",
70+
"typescript": "^5.0.4"
71+
}
72+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { ContextMenuItemGroup } from './types';
2+
3+
const CONTEXT_MENUS_SYMBOL = Symbol('context_menus');
4+
5+
export type EnhancedMouseEvent = MouseEvent & {
6+
[CONTEXT_MENUS_SYMBOL]?: ContextMenuItemGroup[];
7+
};
8+
9+
export function getContextMenuContent(
10+
event: EnhancedMouseEvent
11+
): ContextMenuItemGroup[] {
12+
return event[CONTEXT_MENUS_SYMBOL] ?? [];
13+
}
14+
15+
export function appendContextMenuContent(
16+
event: EnhancedMouseEvent,
17+
content: ContextMenuItemGroup
18+
) {
19+
// Initialize if not already patched
20+
if (!event[CONTEXT_MENUS_SYMBOL]) {
21+
event[CONTEXT_MENUS_SYMBOL] = [];
22+
}
23+
event[CONTEXT_MENUS_SYMBOL].push(content);
24+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import React, {
2+
useCallback,
3+
useEffect,
4+
useState,
5+
useMemo,
6+
createContext,
7+
} from 'react';
8+
import type { ContextMenuContextType, ContextMenuState } from './types';
9+
import type { EnhancedMouseEvent } from './context-menu-content';
10+
import { getContextMenuContent } from './context-menu-content';
11+
12+
export const ContextMenuContext = createContext<ContextMenuContextType | null>(
13+
null
14+
);
15+
16+
export function ContextMenuProvider({
17+
children,
18+
menuWrapper,
19+
}: {
20+
children: React.ReactNode;
21+
menuWrapper: React.ComponentType<{
22+
menu: ContextMenuState & { close: () => void };
23+
}>;
24+
}) {
25+
const [menu, setMenu] = useState<ContextMenuState>({
26+
isOpen: false,
27+
itemGroups: [],
28+
position: { x: 0, y: 0 },
29+
});
30+
const close = useCallback(() => setMenu({ ...menu, isOpen: false }), [menu]);
31+
32+
const handleClosingEvent = useCallback(
33+
(event: Event) => {
34+
if (!event.defaultPrevented) {
35+
setMenu({ ...menu, isOpen: false });
36+
}
37+
},
38+
[menu]
39+
);
40+
41+
useEffect(() => {
42+
function handleContextMenu(event: MouseEvent) {
43+
event.preventDefault();
44+
45+
const itemGroups = getContextMenuContent(event as EnhancedMouseEvent);
46+
47+
if (itemGroups.length === 0) {
48+
return;
49+
}
50+
51+
setMenu({
52+
isOpen: true,
53+
itemGroups,
54+
position: {
55+
// TODO: Fix handling offset while scrolling
56+
x: event.clientX,
57+
y: event.clientY,
58+
},
59+
});
60+
}
61+
62+
document.addEventListener('contextmenu', handleContextMenu);
63+
window.addEventListener('resize', handleClosingEvent);
64+
65+
return () => {
66+
document.removeEventListener('contextmenu', handleContextMenu);
67+
window.removeEventListener('resize', handleClosingEvent);
68+
};
69+
}, [handleClosingEvent]);
70+
71+
const value = useMemo(
72+
() => ({
73+
close,
74+
}),
75+
[close]
76+
);
77+
78+
const Wrapper = menuWrapper ?? React.Fragment;
79+
80+
return (
81+
<ContextMenuContext.Provider value={value}>
82+
{children}
83+
<Wrapper menu={{ ...menu, close }} />
84+
</ContextMenuContext.Provider>
85+
);
86+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { useContextMenu } from './use-context-menu';
2+
export { ContextMenuProvider } from './context-menu-provider';
3+
export type {
4+
ContextMenuItem,
5+
ContextMenuItemGroup,
6+
ContextMenuWrapperProps,
7+
} from './types';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
};
18+
19+
export type ContextMenuContextType = {
20+
close(): void;
21+
};
22+
23+
export type ContextMenuItem = {
24+
label: string;
25+
onAction: (event: React.KeyboardEvent | React.MouseEvent) => void;
26+
};

0 commit comments

Comments
 (0)