Skip to content

Commit c824499

Browse files
committed
feat(compass-context-menu): add a headless context menu package
1 parent 02c5921 commit c824499

13 files changed

+399
-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');
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/tsconfig-compass": "^1.2.8",
59+
"@types/chai": "^4.2.21",
60+
"@types/mocha": "^9.0.0",
61+
"@types/react": "^17.0.5",
62+
"@types/react-dom": "^17.0.10",
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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const CONTEXT_MENUS_SYMBOL = Symbol('context_menus');
2+
3+
export type EnhancedMouseEvent = MouseEvent & {
4+
[CONTEXT_MENUS_SYMBOL]?: React.ComponentType[];
5+
};
6+
7+
export function getContextMenuContent(
8+
event: EnhancedMouseEvent
9+
): React.ComponentType[] {
10+
return event[CONTEXT_MENUS_SYMBOL] ?? [];
11+
}
12+
13+
export function appendContextMenuContent(
14+
event: EnhancedMouseEvent,
15+
content: React.ComponentType
16+
) {
17+
// Initialize if not already patched
18+
if (event[CONTEXT_MENUS_SYMBOL] === undefined) {
19+
event[CONTEXT_MENUS_SYMBOL] = [content];
20+
return;
21+
}
22+
event[CONTEXT_MENUS_SYMBOL].push(content);
23+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import React, {
2+
useCallback,
3+
useEffect,
4+
useState,
5+
useMemo,
6+
createContext,
7+
} from 'react';
8+
import type { ContextMenuContext, MenuState } from './types';
9+
import { ContextMenu } from './context-menu';
10+
import type { EnhancedMouseEvent } from './context-menu-content';
11+
import { getContextMenuContent } from './context-menu-content';
12+
13+
export const Context = createContext<ContextMenuContext | null>(null);
14+
15+
export function ContextMenuProvider({
16+
children,
17+
}: React.PropsWithChildren<never>) {
18+
const [menu, setMenu] = useState<MenuState>({ isOpen: false });
19+
const close = useCallback(() => setMenu({ isOpen: false }), [setMenu]);
20+
21+
useEffect(() => {
22+
function handleContextMenu(event: MouseEvent) {
23+
event.preventDefault();
24+
setMenu({
25+
isOpen: true,
26+
children: getContextMenuContent(event as EnhancedMouseEvent).map(
27+
(Content, index) => <Content key={`menu-content-${index}`} />
28+
),
29+
position: {
30+
// TODO: Fix handling offset while scrolling
31+
x: event.clientX,
32+
y: event.clientY,
33+
},
34+
});
35+
}
36+
document.addEventListener('contextmenu', handleContextMenu);
37+
38+
function handleClosingEvent(event: Event) {
39+
if (!event.defaultPrevented) {
40+
setMenu({ isOpen: false });
41+
}
42+
}
43+
document.addEventListener('click', handleClosingEvent);
44+
window.addEventListener('resize', handleClosingEvent);
45+
46+
return () => {
47+
document.removeEventListener('contextmenu', handleContextMenu);
48+
document.removeEventListener('click', handleClosingEvent);
49+
window.removeEventListener('resize', handleClosingEvent);
50+
};
51+
}, [setMenu]);
52+
53+
const value = useMemo(
54+
() => ({
55+
close,
56+
}),
57+
[close]
58+
);
59+
60+
return (
61+
<>
62+
<Context.Provider value={value}>{children}</Context.Provider>
63+
{menu.isOpen && (
64+
<ContextMenu position={menu.position}>{menu.children}</ContextMenu>
65+
)}
66+
</>
67+
);
68+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { createPortal } from 'react-dom';
2+
import React from 'react';
3+
4+
type ContextMenuProps = React.PropsWithChildren<{
5+
position: {
6+
x: number;
7+
y: number;
8+
};
9+
}>;
10+
11+
export function ContextMenu({ children, position }: ContextMenuProps) {
12+
const container = document.getElementById('context-menu-container');
13+
if (container === null) {
14+
throw new Error('Expected a container for the context menu in the DOM');
15+
}
16+
return createPortal(
17+
<div className="context-menu" style={{ left: position.x, top: position.y }}>
18+
{children}
19+
</div>,
20+
container
21+
);
22+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
};
13+
14+
export type ContextMenuContext = {
15+
close(): void;
16+
};
17+
18+
export type MenuItem = {
19+
label: string;
20+
onAction: (event: Event) => void;
21+
};

0 commit comments

Comments
 (0)