|
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