@@ -17,15 +17,18 @@ import SaveIcon from '@mui/icons-material/Save';
1717import DeleteIcon from '@mui/icons-material/Delete' ;
1818import CopyIcon from '@mui/icons-material/CopyAll' ;
1919import JSONIcon from '@mui/icons-material/DataObject' ;
20+ import MoreVertIcon from '@mui/icons-material/MoreVert' ;
2021import { copyToClipboard } from '../../common/utils'
2122import RepoIcon from '../repos/RepoIcon'
23+ import { splitSecondaryActionsByVisibility } from './controlsLayout'
2224
2325const IkonButton = ( { title, icon, onClick, color, disabled, id} ) => {
2426 return (
25- < Tooltip title = { title } >
27+ < Tooltip title = { title } placement = 'bottom' >
2628 < span >
2729 < IconButton
2830 id = { id }
31+ aria-label = { title }
2932 color = { color }
3033 size = 'small'
3134 sx = { { textTransform : 'none' , margin : '2px 5px' } }
@@ -41,61 +44,145 @@ const IkonButton = ({title, icon, onClick, color, disabled, id}) => {
4144
4245const Controls = ( { project, onDownload, onSave, onDelete, owner, file, isSaving, onImport, importResponse, onDownloadImportReport, onProjectLogsClick, isProjectsLogOpen, configure, setConfigure, isCoreUser, loadingMatches, onCopyClick} ) => {
4346 const { t } = useTranslation ( ) ;
44- const [ anchorEl , setAnchorEl ] = React . useState ( null ) ;
45- const downloadOpen = Boolean ( anchorEl ) ;
47+ const toolbarRef = React . useRef ( null )
48+ const overflowButtonRef = React . useRef ( null )
49+ const [ downloadAnchorEl , setDownloadAnchorEl ] = React . useState ( null ) ;
50+ const [ overflowAnchorEl , setOverflowAnchorEl ] = React . useState ( null ) ;
51+ const [ toolbarWidth , setToolbarWidth ] = React . useState ( Number . POSITIVE_INFINITY )
52+ const downloadOpen = Boolean ( downloadAnchorEl ) ;
53+ const overflowOpen = Boolean ( overflowAnchorEl ) ;
4654 const lastSavedText = project ?. updated_at ? moment ( project . updated_at ) . fromNow ( ) : false
4755 const isRunningImport = [ 'STARTED' , 'RECEIVED' , 'PENDING' ] . includes ( importResponse ?. state )
56+ const hasPersistentOverflowActions = Boolean ( project ?. id )
57+
58+ React . useEffect ( ( ) => {
59+ const node = toolbarRef . current
60+ if ( ! node ) return undefined
61+
62+ const updateToolbarWidth = ( ) => {
63+ const nextWidth = node . getBoundingClientRect ?. ( ) . width
64+ setToolbarWidth ( nextWidth || Number . POSITIVE_INFINITY )
65+ }
66+
67+ updateToolbarWidth ( )
68+
69+ if ( typeof ResizeObserver !== 'undefined' ) {
70+ const observer = new ResizeObserver ( updateToolbarWidth )
71+ observer . observe ( node )
72+ return ( ) => observer . disconnect ( )
73+ }
74+
75+ window . addEventListener ( 'resize' , updateToolbarWidth )
76+ return ( ) => window . removeEventListener ( 'resize' , updateToolbarWidth )
77+ } , [ ] )
78+
79+ const actionsByKey = {
80+ settings : {
81+ key : 'settings' ,
82+ color : configure ? 'primary' : 'secondary' ,
83+ disabled : loadingMatches ,
84+ icon : < SettingsIcon /> ,
85+ onClick : ( ) => setConfigure ( ! configure ) ,
86+ title : t ( 'map_project.configure_mapping_project_tooltip' )
87+ } ,
88+ timeline : {
89+ key : 'timeline' ,
90+ color : isProjectsLogOpen ? 'primary' : 'secondary' ,
91+ icon : < TimelineIcon /> ,
92+ onClick : onProjectLogsClick ,
93+ title : t ( 'map_project.project_logs_tooltip' )
94+ } ,
95+ download : {
96+ key : 'download' ,
97+ color : downloadOpen ? 'primary' : 'secondary' ,
98+ disabled : loadingMatches ,
99+ icon : < DownloadIcon /> ,
100+ onClick : event => setDownloadAnchorEl ( event . currentTarget ) ,
101+ title : t ( 'map_project.download_this_project_as_csv' )
102+ } ,
103+ save : {
104+ key : 'save' ,
105+ color : 'secondary' ,
106+ disabled : ! owner || ! file ?. name || isSaving || loadingMatches ,
107+ icon : < SaveIcon /> ,
108+ onClick : onSave ,
109+ title : t ( 'map_project.save_this_project' )
110+ }
111+ }
112+
113+ const { overflowActionKeys, visibleActionKeys } = splitSecondaryActionsByVisibility ( {
114+ toolbarWidth,
115+ hasOverflowItems : hasPersistentOverflowActions
116+ } )
117+
118+ const visibleSecondaryActions = visibleActionKeys . map ( key => actionsByKey [ key ] )
119+ const overflowSecondaryActions = overflowActionKeys . map ( key => actionsByKey [ key ] )
120+ const hasOverflowActions = hasPersistentOverflowActions || overflowSecondaryActions . length > 0
121+
122+ const closeDownloadMenu = ( ) => setDownloadAnchorEl ( null )
123+ const closeOverflowMenu = ( ) => setOverflowAnchorEl ( null )
124+
125+ const onOverflowActionClick = action => event => {
126+ closeOverflowMenu ( )
127+
128+ if ( action . key === 'download' ) {
129+ setDownloadAnchorEl ( overflowButtonRef . current || event . currentTarget )
130+ return
131+ }
132+
133+ action . onClick ( event )
134+ }
48135
49136 return (
50- < span style = { { textAlign : 'right' } } >
51- < div >
52- < IkonButton
53- color = { configure ? 'primary' : 'secondary' }
54- onClick = { ( ) => setConfigure ( ! configure ) }
55- title = { t ( 'map_project.configure_mapping_project_tooltip' ) }
56- icon = { < SettingsIcon /> }
57- disabled = { loadingMatches }
58- />
59- < IkonButton
60- color = { isProjectsLogOpen ? 'primary' : 'secondary' }
61- onClick = { onProjectLogsClick }
62- title = { t ( 'map_project.project_logs_tooltip' ) }
63- icon = { < TimelineIcon /> }
64- />
65- < IkonButton
66- color = 'secondary'
67- onClick = { onSave }
68- title = { t ( 'map_project.save_this_project' ) }
69- disabled = { ! owner || ! file ?. name || isSaving || loadingMatches }
70- icon = { < SaveIcon /> }
71- />
72- < IkonButton
73- id = 'download-button'
74- color = { downloadOpen ? 'primary' : 'secondary' }
75- onClick = { event => setAnchorEl ( event . currentTarget ) }
76- title = { t ( 'map_project.download_this_project_as_csv' ) }
77- icon = { < DownloadIcon /> }
78- disabled = { loadingMatches }
79- />
137+ < span ref = { toolbarRef } style = { { display : 'flex' , flex : '1 1 auto' , flexDirection : 'column' , alignItems : 'flex-end' , maxWidth : '100%' , minWidth : 0 } } >
138+ < div style = { { display : 'flex' , flexWrap : 'wrap' , alignItems : 'center' , justifyContent : 'flex-end' , alignContent : 'flex-end' , width : '100%' , minWidth : 0 } } >
80139 {
81- project ?. id &&
140+ visibleSecondaryActions . map ( action => (
82141 < IkonButton
83- id = 'copy-button'
84- color = 'secondary'
85- onClick = { onCopyClick }
86- title = { t ( 'map_project.create_similar' ) }
87- icon = { < CopyIcon /> }
142+ key = { action . key }
143+ color = { action . color }
144+ onClick = { action . onClick }
145+ title = { action . title }
146+ icon = { action . icon }
147+ disabled = { action . disabled }
148+ id = { action . key === 'download' ? 'download-button' : undefined }
88149 />
150+ ) )
89151 }
90152 {
91- project ?. id &&
92- < IkonButton
93- color = 'secondary'
94- disabled = { isSaving || loadingMatches }
95- onClick = { onDelete }
96- title = { t ( 'map_project.delete_this_project' ) }
97- icon = { < DeleteIcon /> }
98- />
153+ visibleSecondaryActions . length > 0 &&
154+ < Divider flexItem orientation = 'vertical' sx = { { mx : 0.5 , my : 0.75 } } />
155+ }
156+ < IkonButton
157+ color = { actionsByKey . save . color }
158+ onClick = { actionsByKey . save . onClick }
159+ title = { actionsByKey . save . title }
160+ disabled = { actionsByKey . save . disabled }
161+ icon = { actionsByKey . save . icon }
162+ />
163+ {
164+ hasOverflowActions &&
165+ < >
166+ < Divider flexItem orientation = 'vertical' sx = { { mx : 0.5 , my : 0.75 } } />
167+ < Tooltip title = { t ( 'common.more_actions' ) } placement = 'bottom' >
168+ < span >
169+ < IconButton
170+ id = 'map-project-controls-overflow-button'
171+ ref = { overflowButtonRef }
172+ color = { overflowOpen ? 'primary' : 'secondary' }
173+ size = 'small'
174+ aria-label = { t ( 'common.more_actions' ) }
175+ aria-haspopup = 'menu'
176+ aria-controls = { overflowOpen ? 'map-project-controls-overflow-menu' : undefined }
177+ aria-expanded = { overflowOpen ? 'true' : undefined }
178+ sx = { { textTransform : 'none' , margin : '2px 5px' } }
179+ onClick = { event => setOverflowAnchorEl ( event . currentTarget ) }
180+ >
181+ < MoreVertIcon />
182+ </ IconButton >
183+ </ span >
184+ </ Tooltip >
185+ </ >
99186 }
100187 </ div >
101188 {
@@ -131,37 +218,78 @@ const Controls = ({project, onDownload, onSave, onDelete, owner, file, isSaving,
131218
132219 < Menu
133220 id = "download-menu"
134- anchorEl = { anchorEl }
221+ anchorEl = { downloadAnchorEl }
135222 open = { downloadOpen }
136- onClose = { ( ) => setAnchorEl ( null ) }
223+ onClose = { closeDownloadMenu }
224+ anchorOrigin = { { vertical : 'bottom' , horizontal : 'right' } }
225+ transformOrigin = { { vertical : 'top' , horizontal : 'right' } }
137226 slotProps = { {
138227 list : {
139- 'aria-labelledby' : 'download-button' ,
228+ 'aria-labelledby' : downloadAnchorEl ?. id ,
140229 role : 'listbox' ,
141230 } ,
142231 } }
143232 >
144- < MenuItem onClick = { ( ) => { setAnchorEl ( null ) ; onDownload ( 'csv' ) ; } } >
233+ < MenuItem onClick = { ( ) => { closeDownloadMenu ( ) ; onDownload ( 'csv' ) ; } } >
145234 < ListItemIcon >
146235 < i className = "fa-solid fa-file-csv" style = { { fontSize : '1.25rem' } } > </ i >
147236 </ ListItemIcon >
148237 < ListItemText > { t ( 'common.download_csv' ) } </ ListItemText >
149238 </ MenuItem >
150- < MenuItem onClick = { ( ) => { setAnchorEl ( null ) ; onDownload ( 'candidates_metadata' ) ; } } disabled = { ! isCoreUser } >
239+ < MenuItem onClick = { ( ) => { closeDownloadMenu ( ) ; onDownload ( 'candidates_metadata' ) ; } } disabled = { ! isCoreUser } >
151240 < ListItemIcon >
152241 < JSONIcon sx = { { fontSize : '1.25rem' } } />
153242 </ ListItemIcon >
154243 < ListItemText > { t ( 'map_project.candidates_metadata' ) } </ ListItemText >
155244 </ MenuItem >
156245 < Divider />
157- < MenuItem onClick = { ( ) => { setAnchorEl ( null ) ; onImport ( ) ; } } disabled = { ! onImport || isRunningImport } >
246+ < MenuItem onClick = { ( ) => { closeDownloadMenu ( ) ; onImport ( ) ; } } disabled = { ! onImport || isRunningImport } >
158247 < ListItemIcon >
159248 < RepoIcon noTooltip />
160249 </ ListItemIcon >
161250 < ListItemText > { t ( 'map_project.save_to_collection' ) } </ ListItemText >
162251 { isRunningImport ? < CircularProgress sx = { { marginLeft : '16px' } } size = { 20 } /> : null }
163252 </ MenuItem >
164253 </ Menu >
254+ < Menu
255+ id = 'map-project-controls-overflow-menu'
256+ anchorEl = { overflowAnchorEl }
257+ open = { overflowOpen }
258+ onClose = { closeOverflowMenu }
259+ anchorOrigin = { { vertical : 'bottom' , horizontal : 'right' } }
260+ transformOrigin = { { vertical : 'top' , horizontal : 'right' } }
261+ >
262+ {
263+ overflowSecondaryActions . map ( action => (
264+ < MenuItem key = { action . key } onClick = { onOverflowActionClick ( action ) } disabled = { action . disabled } >
265+ < ListItemIcon > { action . icon } </ ListItemIcon >
266+ < ListItemText > { action . title } </ ListItemText >
267+ </ MenuItem >
268+ ) )
269+ }
270+ {
271+ overflowSecondaryActions . length > 0 && project ?. id &&
272+ < Divider />
273+ }
274+ {
275+ project ?. id &&
276+ < MenuItem onClick = { event => { onCopyClick ( event ) ; closeOverflowMenu ( ) ; } } >
277+ < ListItemIcon > < CopyIcon fontSize = 'small' /> </ ListItemIcon >
278+ < ListItemText > { t ( 'map_project.create_similar' ) } </ ListItemText >
279+ </ MenuItem >
280+ }
281+ {
282+ project ?. id &&
283+ < Divider />
284+ }
285+ {
286+ project ?. id &&
287+ < MenuItem onClick = { ( ) => { closeOverflowMenu ( ) ; onDelete ( ) ; } } disabled = { isSaving || loadingMatches } sx = { { color : 'error.main' } } >
288+ < ListItemIcon sx = { { color : 'error.main' } } > < DeleteIcon fontSize = 'small' /> </ ListItemIcon >
289+ < ListItemText > { t ( 'common.delete' ) } </ ListItemText >
290+ </ MenuItem >
291+ }
292+ </ Menu >
165293 </ span >
166294
167295 )
0 commit comments