Skip to content

Commit 12cadde

Browse files
committed
Clamp context menu within viewport
1 parent bc82505 commit 12cadde

1 file changed

Lines changed: 107 additions & 7 deletions

File tree

components/ContextMenu.tsx

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useRef } from 'react';
1+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
22
import ReactDOM from 'react-dom';
33

44
export type MenuItem = {
@@ -16,8 +16,62 @@ interface ContextMenuProps {
1616
onClose: () => void;
1717
}
1818

19+
const EDGE_MARGIN = 8;
20+
1921
const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onClose }) => {
2022
const menuRef = useRef<HTMLDivElement>(null);
23+
const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxHeight: number; overflowY: React.CSSProperties['overflowY'] }>({
24+
top: position.y,
25+
left: position.x,
26+
maxHeight: 0,
27+
overflowY: 'visible',
28+
});
29+
30+
const recalculatePosition = useCallback(() => {
31+
const menu = menuRef.current;
32+
if (!menu) return;
33+
34+
const { innerWidth, innerHeight } = window;
35+
const rect = menu.getBoundingClientRect();
36+
const maxHeight = Math.max(innerHeight - EDGE_MARGIN * 2, 0);
37+
38+
let left = rect.left;
39+
let top = rect.top;
40+
41+
if (rect.right > innerWidth - EDGE_MARGIN) {
42+
left = Math.max(EDGE_MARGIN, innerWidth - rect.width - EDGE_MARGIN);
43+
}
44+
if (left < EDGE_MARGIN) {
45+
left = EDGE_MARGIN;
46+
}
47+
48+
if (rect.bottom > innerHeight - EDGE_MARGIN) {
49+
top = Math.max(EDGE_MARGIN, innerHeight - rect.height - EDGE_MARGIN);
50+
}
51+
if (top < EDGE_MARGIN) {
52+
top = EDGE_MARGIN;
53+
}
54+
55+
const overflowY: React.CSSProperties['overflowY'] = rect.height > maxHeight ? 'auto' : 'visible';
56+
57+
setMenuStyle((previous) => {
58+
if (
59+
previous.top === top &&
60+
previous.left === left &&
61+
previous.maxHeight === maxHeight &&
62+
previous.overflowY === overflowY
63+
) {
64+
return previous;
65+
}
66+
67+
return {
68+
top,
69+
left,
70+
maxHeight,
71+
overflowY,
72+
};
73+
});
74+
}, []);
2175

2276
useEffect(() => {
2377
if (!isOpen) return;
@@ -41,20 +95,66 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ isOpen, position, items, onCl
4195
};
4296
}, [isOpen, onClose]);
4397

98+
useLayoutEffect(() => {
99+
if (!isOpen) return;
100+
101+
setMenuStyle((previous) => {
102+
if (previous.top === position.y && previous.left === position.x) {
103+
return previous;
104+
}
105+
106+
return {
107+
top: position.y,
108+
left: position.x,
109+
maxHeight: previous.maxHeight,
110+
overflowY: previous.overflowY,
111+
};
112+
});
113+
114+
const frame = requestAnimationFrame(() => {
115+
recalculatePosition();
116+
});
117+
118+
return () => cancelAnimationFrame(frame);
119+
}, [isOpen, position.x, position.y, recalculatePosition]);
120+
121+
useLayoutEffect(() => {
122+
if (!isOpen) return;
123+
124+
const frame = requestAnimationFrame(() => {
125+
recalculatePosition();
126+
});
127+
128+
return () => cancelAnimationFrame(frame);
129+
}, [isOpen, items, recalculatePosition]);
130+
131+
useEffect(() => {
132+
if (!isOpen) return;
133+
134+
const handleResize = () => {
135+
recalculatePosition();
136+
};
137+
138+
window.addEventListener('resize', handleResize);
139+
return () => {
140+
window.removeEventListener('resize', handleResize);
141+
};
142+
}, [isOpen, recalculatePosition]);
143+
44144
if (!isOpen) return null;
45-
46-
const menuStyle: React.CSSProperties = {
47-
top: position.y,
48-
left: position.x,
49-
};
50145

51146
const overlayRoot = document.getElementById('overlay-root');
52147
if (!overlayRoot) return null;
53148

54149
return ReactDOM.createPortal(
55150
<div
56151
ref={menuRef}
57-
style={menuStyle}
152+
style={{
153+
top: menuStyle.top,
154+
left: menuStyle.left,
155+
maxHeight: menuStyle.maxHeight ? menuStyle.maxHeight : undefined,
156+
overflowY: menuStyle.overflowY,
157+
}}
58158
className="fixed z-50 w-56 rounded-md bg-secondary p-1.5 shadow-2xl border border-border-color animate-fade-in-fast"
59159
>
60160
<ul className="space-y-1">

0 commit comments

Comments
 (0)