5
5
* This source code is licensed under the license found in the LICENSE file in
6
6
* the root directory of this source tree.
7
7
*/
8
+
8
9
import PropTypes from 'lib/PropTypes' ;
9
10
import React , { useState , useEffect , useRef } from 'react' ;
10
11
import styles from 'components/ContextMenu/ContextMenu.scss' ;
11
12
12
- const getPositionToFitVisibleScreen = ref => {
13
- if ( ref . current ) {
14
- const elBox = ref . current . getBoundingClientRect ( ) ;
15
- const y = elBox . y + elBox . height < window . innerHeight ? 0 : 0 - elBox . y + 100 ;
16
-
17
- // If there's a previous element show current next to it.
18
- // Try on right side first, then on left if there's no place.
19
- const prevEl = ref . current . previousSibling ;
20
- if ( prevEl ) {
21
- const prevElBox = prevEl . getBoundingClientRect ( ) ;
22
- const showOnRight = prevElBox . x + prevElBox . width + elBox . width < window . innerWidth ;
23
- return {
24
- x : showOnRight ? prevElBox . width : - elBox . width ,
25
- y,
26
- } ;
27
- }
13
+ const getPositionToFitVisibleScreen = (
14
+ ref ,
15
+ offset = 0 ,
16
+ mainItemCount = 0 ,
17
+ subItemCount = 0
18
+ ) => {
19
+ if ( ! ref . current ) {
20
+ return ;
21
+ }
22
+
23
+ const elBox = ref . current . getBoundingClientRect ( ) ;
24
+ const menuHeight = elBox . height ;
25
+ const footerHeight = 50 ;
26
+ const lowerLimit = window . innerHeight - footerHeight ;
27
+ const upperLimit = 0 ;
28
+
29
+ const shouldApplyOffset = mainItemCount === 0 || subItemCount > mainItemCount ;
30
+ const prevEl = ref . current . previousSibling ;
31
+
32
+ if ( prevEl ) {
33
+ const prevElBox = prevEl . getBoundingClientRect ( ) ;
34
+ const showOnRight = prevElBox . x + prevElBox . width + elBox . width < window . innerWidth ;
28
35
29
- return { x : 0 , y } ;
36
+ let proposedTop = shouldApplyOffset
37
+ ? prevElBox . top + offset
38
+ : prevElBox . top ;
39
+
40
+ proposedTop = Math . max ( upperLimit , Math . min ( proposedTop , lowerLimit - menuHeight ) ) ;
41
+
42
+ return {
43
+ x : showOnRight ? prevElBox . width : - elBox . width ,
44
+ y : proposedTop - elBox . top ,
45
+ } ;
30
46
}
47
+
48
+ const proposedTop = elBox . top + offset ;
49
+ const clampedTop = Math . max ( upperLimit , Math . min ( proposedTop , lowerLimit - menuHeight ) ) ;
50
+ return {
51
+ x : 0 ,
52
+ y : clampedTop - elBox . top ,
53
+ } ;
31
54
} ;
32
55
33
- const MenuSection = ( { level, items, path, setPath, hide } ) => {
56
+ const MenuSection = ( { level, items, path, setPath, hide, parentItemCount = 0 } ) => {
34
57
const sectionRef = useRef ( null ) ;
35
- const [ position , setPosition ] = useState ( ) ;
58
+ const [ position , setPosition ] = useState ( null ) ;
59
+ const hasPositioned = useRef ( false ) ;
36
60
37
61
useEffect ( ( ) => {
38
- const newPosition = getPositionToFitVisibleScreen ( sectionRef ) ;
39
- newPosition && setPosition ( newPosition ) ;
40
- } , [ sectionRef ] ) ;
62
+ if ( ! hasPositioned . current ) {
63
+ const newPosition = getPositionToFitVisibleScreen (
64
+ sectionRef ,
65
+ path [ level ] * 30 ,
66
+ parentItemCount ,
67
+ items . length
68
+ ) ;
69
+ if ( newPosition ) {
70
+ setPosition ( newPosition ) ;
71
+ hasPositioned . current = true ;
72
+ }
73
+ }
74
+ } , [ ] ) ;
41
75
42
76
const style = position
43
77
? {
44
- left : position . x ,
45
- top : position . y + path [ level ] * 30 ,
78
+ transform : `translate(${ position . x } px, ${ position . y } px)` ,
46
79
maxHeight : '80vh' ,
47
- overflowY : 'scroll ' ,
80
+ overflowY : 'auto ' ,
48
81
opacity : 1 ,
82
+ position : 'absolute' ,
49
83
}
50
84
: { } ;
51
85
52
86
return (
53
87
< ul ref = { sectionRef } className = { styles . category } style = { style } >
54
88
{ items . map ( ( item , index ) => {
55
- if ( item . items ) {
56
- return (
57
- < li
58
- key = { `menu-section-${ level } -${ index } ` }
59
- className = { styles . item }
60
- onMouseEnter = { ( ) => {
61
- const newPath = path . slice ( 0 , level + 1 ) ;
62
- newPath . push ( index ) ;
63
- setPath ( newPath ) ;
64
- } }
65
- >
66
- { item . text }
67
- </ li >
68
- ) ;
69
- }
89
+ const handleHover = ( ) => {
90
+ const newPath = path . slice ( 0 , level + 1 ) ;
91
+ newPath . push ( index ) ;
92
+ setPath ( newPath ) ;
93
+ } ;
94
+
70
95
return (
71
96
< li
72
97
key = { `menu-section-${ level } -${ index } ` }
73
- className = { styles . option }
98
+ className = { item . items ? styles . item : styles . option }
74
99
style = { item . disabled ? { opacity : 0.5 , cursor : 'not-allowed' } : { } }
75
100
onClick = { ( ) => {
76
- if ( item . disabled === true ) {
77
- return ;
101
+ if ( ! item . disabled ) {
102
+ item . callback ?. ( ) ;
103
+ hide ( ) ;
78
104
}
79
- item . callback && item . callback ( ) ;
80
- hide ( ) ;
81
105
} }
106
+ onMouseEnter = { handleHover }
82
107
>
83
108
{ item . text }
84
109
{ item . subtext && < span > - { item . subtext } </ span > }
@@ -92,6 +117,8 @@ const MenuSection = ({ level, items, path, setPath, hide }) => {
92
117
const ContextMenu = ( { x, y, items } ) => {
93
118
const [ path , setPath ] = useState ( [ 0 ] ) ;
94
119
const [ visible , setVisible ] = useState ( true ) ;
120
+ const menuRef = useRef ( null ) ;
121
+
95
122
useEffect ( ( ) => {
96
123
setVisible ( true ) ;
97
124
} , [ items ] ) ;
@@ -101,33 +128,26 @@ const ContextMenu = ({ x, y, items }) => {
101
128
setPath ( [ 0 ] ) ;
102
129
} ;
103
130
104
- //#region Closing menu after clicking outside it
105
-
106
- const menuRef = useRef ( null ) ;
107
-
108
- function handleClickOutside ( event ) {
109
- if ( menuRef . current && ! menuRef . current . contains ( event . target ) ) {
110
- hide ( ) ;
111
- }
112
- }
113
-
114
131
useEffect ( ( ) => {
132
+ const handleClickOutside = event => {
133
+ if ( menuRef . current && ! menuRef . current . contains ( event . target ) ) {
134
+ hide ( ) ;
135
+ }
136
+ } ;
115
137
document . addEventListener ( 'mousedown' , handleClickOutside ) ;
116
138
return ( ) => {
117
139
document . removeEventListener ( 'mousedown' , handleClickOutside ) ;
118
140
} ;
119
- } ) ;
120
-
121
- //#endregion
141
+ } , [ ] ) ;
122
142
123
143
if ( ! visible ) {
124
144
return null ;
125
145
}
126
146
127
147
const getItemsFromLevel = level => {
128
148
let result = items ;
129
- for ( let index = 1 ; index <= level ; index ++ ) {
130
- result = result [ path [ index ] ] . items ;
149
+ for ( let i = 1 ; i <= level ; i ++ ) {
150
+ result = result [ path [ i ] ] ? .items || [ ] ;
131
151
}
132
152
return result ;
133
153
} ;
@@ -136,20 +156,22 @@ const ContextMenu = ({ x, y, items }) => {
136
156
< div
137
157
className = { styles . menu }
138
158
ref = { menuRef }
139
- style = { {
140
- left : x ,
141
- top : y ,
142
- } }
159
+ style = { { left : x , top : y , position : 'absolute' } }
143
160
>
144
- { path . map ( ( position , level ) => {
161
+ { path . map ( ( _ , level ) => {
162
+ const itemsForLevel = getItemsFromLevel ( level ) ;
163
+ const parentItemCount =
164
+ level === 0 ? items . length : getItemsFromLevel ( level - 1 ) . length ;
165
+
145
166
return (
146
167
< MenuSection
147
- key = { `section-${ position } -${ level } ` }
168
+ key = { `section-${ path [ level ] } -${ level } ` }
148
169
path = { path }
149
170
setPath = { setPath }
150
171
level = { level }
151
- items = { getItemsFromLevel ( level ) }
172
+ items = { itemsForLevel }
152
173
hide = { hide }
174
+ parentItemCount = { parentItemCount }
153
175
/>
154
176
) ;
155
177
} ) }
@@ -160,9 +182,7 @@ const ContextMenu = ({ x, y, items }) => {
160
182
ContextMenu . propTypes = {
161
183
x : PropTypes . number . isRequired . describe ( 'X context menu position.' ) ,
162
184
y : PropTypes . number . isRequired . describe ( 'Y context menu position.' ) ,
163
- items : PropTypes . array . isRequired . describe (
164
- 'Array with tree representation of context menu items.'
165
- ) ,
185
+ items : PropTypes . array . isRequired . describe ( 'Array with tree representation of context menu items.' ) ,
166
186
} ;
167
187
168
188
export default ContextMenu ;
0 commit comments