55 * This source code is licensed under the license found in the LICENSE file in
66 * the root directory of this source tree.
77 */
8+
89import PropTypes from 'lib/PropTypes' ;
910import React , { useState , useEffect , useRef } from 'react' ;
1011import styles from 'components/ContextMenu/ContextMenu.scss' ;
1112
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 ;
2835
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+ } ;
3046 }
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+ } ;
3154} ;
3255
33- const MenuSection = ( { level, items, path, setPath, hide } ) => {
56+ const MenuSection = ( { level, items, path, setPath, hide, parentItemCount = 0 } ) => {
3457 const sectionRef = useRef ( null ) ;
35- const [ position , setPosition ] = useState ( ) ;
58+ const [ position , setPosition ] = useState ( null ) ;
59+ const hasPositioned = useRef ( false ) ;
3660
3761 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+ } , [ ] ) ;
4175
4276 const style = position
4377 ? {
44- left : position . x ,
45- top : position . y + path [ level ] * 30 ,
78+ transform : `translate(${ position . x } px, ${ position . y } px)` ,
4679 maxHeight : '80vh' ,
47- overflowY : 'scroll ' ,
80+ overflowY : 'auto ' ,
4881 opacity : 1 ,
82+ position : 'absolute' ,
4983 }
5084 : { } ;
5185
5286 return (
5387 < ul ref = { sectionRef } className = { styles . category } style = { style } >
5488 { 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+
7095 return (
7196 < li
7297 key = { `menu-section-${ level } -${ index } ` }
73- className = { styles . option }
98+ className = { item . items ? styles . item : styles . option }
7499 style = { item . disabled ? { opacity : 0.5 , cursor : 'not-allowed' } : { } }
75100 onClick = { ( ) => {
76- if ( item . disabled === true ) {
77- return ;
101+ if ( ! item . disabled ) {
102+ item . callback ?. ( ) ;
103+ hide ( ) ;
78104 }
79- item . callback && item . callback ( ) ;
80- hide ( ) ;
81105 } }
106+ onMouseEnter = { handleHover }
82107 >
83108 { item . text }
84109 { item . subtext && < span > - { item . subtext } </ span > }
@@ -92,6 +117,8 @@ const MenuSection = ({ level, items, path, setPath, hide }) => {
92117const ContextMenu = ( { x, y, items } ) => {
93118 const [ path , setPath ] = useState ( [ 0 ] ) ;
94119 const [ visible , setVisible ] = useState ( true ) ;
120+ const menuRef = useRef ( null ) ;
121+
95122 useEffect ( ( ) => {
96123 setVisible ( true ) ;
97124 } , [ items ] ) ;
@@ -101,33 +128,26 @@ const ContextMenu = ({ x, y, items }) => {
101128 setPath ( [ 0 ] ) ;
102129 } ;
103130
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-
114131 useEffect ( ( ) => {
132+ const handleClickOutside = event => {
133+ if ( menuRef . current && ! menuRef . current . contains ( event . target ) ) {
134+ hide ( ) ;
135+ }
136+ } ;
115137 document . addEventListener ( 'mousedown' , handleClickOutside ) ;
116138 return ( ) => {
117139 document . removeEventListener ( 'mousedown' , handleClickOutside ) ;
118140 } ;
119- } ) ;
120-
121- //#endregion
141+ } , [ ] ) ;
122142
123143 if ( ! visible ) {
124144 return null ;
125145 }
126146
127147 const getItemsFromLevel = level => {
128148 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 || [ ] ;
131151 }
132152 return result ;
133153 } ;
@@ -136,20 +156,22 @@ const ContextMenu = ({ x, y, items }) => {
136156 < div
137157 className = { styles . menu }
138158 ref = { menuRef }
139- style = { {
140- left : x ,
141- top : y ,
142- } }
159+ style = { { left : x , top : y , position : 'absolute' } }
143160 >
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+
145166 return (
146167 < MenuSection
147- key = { `section-${ position } -${ level } ` }
168+ key = { `section-${ path [ level ] } -${ level } ` }
148169 path = { path }
149170 setPath = { setPath }
150171 level = { level }
151- items = { getItemsFromLevel ( level ) }
172+ items = { itemsForLevel }
152173 hide = { hide }
174+ parentItemCount = { parentItemCount }
153175 />
154176 ) ;
155177 } ) }
@@ -160,9 +182,7 @@ const ContextMenu = ({ x, y, items }) => {
160182ContextMenu . propTypes = {
161183 x : PropTypes . number . isRequired . describe ( 'X context menu position.' ) ,
162184 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.' ) ,
166186} ;
167187
168188export default ContextMenu ;
0 commit comments