Skip to content

Commit 23c5863

Browse files
committed
fix: Improve context menu positioning logic (#26)
1 parent 39db200 commit 23c5863

File tree

1 file changed

+108
-94
lines changed

1 file changed

+108
-94
lines changed
Lines changed: 108 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,108 @@
1-
import clsx from "clsx";
2-
import React, { useLayoutEffect, useRef, useState } from "react";
3-
import useOnClickOutside from "../../hooks/useOnClickOutside";
4-
5-
type MenuItem = {
6-
name: string;
7-
icon: React.ReactNode;
8-
disabled?: boolean;
9-
onClick: () => void;
10-
};
11-
type PropTypes = {
12-
menu: MenuItem[] | MenuItem[][];
13-
} & Omit<React.ComponentProps<"div">, "onContextMenu">;
14-
15-
const ContextMenu = ({ menu, children, ...props }: PropTypes) => {
16-
const [position, setPosition] = useState<{ x: number; y: number } | undefined>(undefined);
17-
18-
const ref = useRef<HTMLDivElement>(null);
19-
20-
// close on click outside
21-
useOnClickOutside(ref, () => setPosition(undefined));
22-
23-
// set the correct position
24-
useLayoutEffect(() => {
25-
if (ref.current && position) {
26-
const { width, height } = ref.current.getBoundingClientRect();
27-
if (position.y + height > window.innerHeight) {
28-
ref.current.style.top = `${position.y - height}px`;
29-
}
30-
if (position.x + width > window.innerWidth && position.x - width > 0) {
31-
ref.current.style.left = `${position.x - width}px`;
32-
}
33-
}
34-
}, [ref, position]);
35-
36-
return (
37-
<>
38-
<div
39-
{...props}
40-
onContextMenu={(e) => {
41-
e.preventDefault();
42-
setPosition({ x: e.pageX, y: e.pageY });
43-
}}
44-
>
45-
{children}
46-
</div>
47-
{position && (
48-
<div
49-
ref={ref}
50-
role="menu"
51-
className="absolute z-20 w-40 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-background shadow dark:divide-gray-600 dark:border-gray-600"
52-
style={{ top: `${position.y}px`, left: `${position.x}px` }}
53-
onClick={() => setPosition(undefined)}
54-
>
55-
{(menu.length > 0 && Array.isArray(menu[0]) && (
56-
<>
57-
{(menu as MenuItem[][]).map((group, i) => (
58-
<ul className="py-1 text-sm text-gray-700 dark:text-gray-200" key={i}>
59-
{group.map((item) => (
60-
<li
61-
role="menuitem"
62-
className={clsx("flex px-2 py-1", item.disabled ? "text-text-disabled" : "hover:bg-background-hover")}
63-
onClick={!item.disabled ? item.onClick : undefined}
64-
key={item.name}
65-
>
66-
<span className="me-2 flex w-4 items-center justify-center">{item.icon}</span>
67-
{item.name}
68-
</li>
69-
))}
70-
</ul>
71-
))}
72-
</>
73-
)) || (
74-
<ul className="py-1 text-sm text-gray-700 dark:text-gray-200">
75-
{(menu as MenuItem[]).map((item) => (
76-
<li
77-
role="menuitem"
78-
className={clsx("flex px-2 py-1", item.disabled ? "text-text-disabled" : "hover:bg-background-hover")}
79-
onClick={!item.disabled ? item.onClick : undefined}
80-
key={item.name}
81-
>
82-
<span className="me-2 flex w-4 items-center justify-center">{item.icon}</span>
83-
{item.name}
84-
</li>
85-
))}
86-
</ul>
87-
)}
88-
</div>
89-
)}
90-
</>
91-
);
92-
};
93-
94-
export default ContextMenu;
1+
import clsx from "clsx";
2+
import React, { useLayoutEffect, useRef, useState } from "react";
3+
import useOnClickOutside from "../../hooks/useOnClickOutside";
4+
5+
type MenuItem = {
6+
name: string;
7+
icon: React.ReactNode;
8+
disabled?: boolean;
9+
onClick: () => void;
10+
};
11+
type PropTypes = {
12+
menu: MenuItem[] | MenuItem[][];
13+
} & Omit<React.ComponentProps<"div">, "onContextMenu">;
14+
15+
const OUTER_SPACING = 5;
16+
17+
const ContextMenu = ({ menu, children, ...props }: PropTypes) => {
18+
const [position, setPosition] = useState<{ x: number; y: number } | undefined>(undefined);
19+
20+
const ref = useRef<HTMLDivElement>(null);
21+
22+
// close on click outside
23+
useOnClickOutside(ref, () => setPosition(undefined));
24+
25+
// set the correct position
26+
useLayoutEffect(() => {
27+
if (ref.current && position) {
28+
const { width, height } = ref.current.getBoundingClientRect();
29+
let newTop = position.y;
30+
let newLeft = position.x;
31+
32+
if (newTop + height + OUTER_SPACING > window.innerHeight) {
33+
newTop = window.innerHeight - height - OUTER_SPACING;
34+
if (newTop < OUTER_SPACING) {
35+
newTop = OUTER_SPACING;
36+
}
37+
}
38+
if (newLeft + width + OUTER_SPACING > window.innerWidth) {
39+
newLeft = window.innerWidth - width - OUTER_SPACING;
40+
if (newLeft < OUTER_SPACING) {
41+
newLeft = OUTER_SPACING;
42+
}
43+
}
44+
45+
ref.current.style.top = `${newTop}px`;
46+
ref.current.style.left = `${newLeft}px`;
47+
}
48+
}, [ref, position]);
49+
50+
return (
51+
<>
52+
<div
53+
{...props}
54+
onContextMenu={(e) => {
55+
e.preventDefault();
56+
setPosition({ x: e.pageX, y: e.pageY });
57+
}}
58+
>
59+
{children}
60+
</div>
61+
{position && (
62+
<div
63+
ref={ref}
64+
role="menu"
65+
className="absolute z-50 w-40 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-background shadow dark:divide-gray-600 dark:border-gray-600"
66+
style={{ top: `${position.y}px`, left: `${position.x}px` }}
67+
onClick={() => setPosition(undefined)}
68+
>
69+
{(menu.length > 0 && Array.isArray(menu[0]) && (
70+
<>
71+
{(menu as MenuItem[][]).map((group, i) => (
72+
<ul className="py-1 text-sm text-gray-700 dark:text-gray-200" key={i}>
73+
{group.map((item) => (
74+
<li
75+
role="menuitem"
76+
className={clsx("flex px-2 py-1", item.disabled ? "text-text-disabled" : "hover:bg-background-hover")}
77+
onClick={!item.disabled ? item.onClick : undefined}
78+
key={item.name}
79+
>
80+
<span className="me-2 flex w-4 items-center justify-center">{item.icon}</span>
81+
{item.name}
82+
</li>
83+
))}
84+
</ul>
85+
))}
86+
</>
87+
)) || (
88+
<ul className="py-1 text-sm text-gray-700 dark:text-gray-200">
89+
{(menu as MenuItem[]).map((item) => (
90+
<li
91+
role="menuitem"
92+
className={clsx("flex px-2 py-1", item.disabled ? "text-text-disabled" : "hover:bg-background-hover")}
93+
onClick={!item.disabled ? item.onClick : undefined}
94+
key={item.name}
95+
>
96+
<span className="me-2 flex w-4 items-center justify-center">{item.icon}</span>
97+
{item.name}
98+
</li>
99+
))}
100+
</ul>
101+
)}
102+
</div>
103+
)}
104+
</>
105+
);
106+
};
107+
108+
export default ContextMenu;

0 commit comments

Comments
 (0)