Skip to content

Commit 659b114

Browse files
authored
Merge pull request #55 from OpenConceptLab/issues#2487
OpenConceptLab/ocl_issues#2487 | Project Toolbar folding to overflow menu on viewport shrink
2 parents a0806b9 + dd1c752 commit 659b114

3 files changed

Lines changed: 276 additions & 53 deletions

File tree

src/components/map-projects/Controls.jsx

Lines changed: 181 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,18 @@ import SaveIcon from '@mui/icons-material/Save';
1717
import DeleteIcon from '@mui/icons-material/Delete';
1818
import CopyIcon from '@mui/icons-material/CopyAll';
1919
import JSONIcon from '@mui/icons-material/DataObject';
20+
import MoreVertIcon from '@mui/icons-material/MoreVert';
2021
import { copyToClipboard } from '../../common/utils'
2122
import RepoIcon from '../repos/RepoIcon'
23+
import { splitSecondaryActionsByVisibility } from './controlsLayout'
2224

2325
const 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

4245
const 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
)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import test from 'node:test'
2+
import assert from 'node:assert/strict'
3+
4+
import {
5+
getVisibleSecondaryActionCount,
6+
splitSecondaryActionsByVisibility
7+
} from '../controlsLayout.js'
8+
9+
test('controlsLayout keeps all secondary actions visible when width is unconstrained', () => {
10+
assert.equal(
11+
getVisibleSecondaryActionCount({ toolbarWidth: Number.POSITIVE_INFINITY, hasOverflowItems: true }),
12+
3
13+
)
14+
})
15+
16+
test('controlsLayout collapses secondary actions leftmost-first when overflow is always present', () => {
17+
assert.deepEqual(
18+
splitSecondaryActionsByVisibility({ toolbarWidth: 260, hasOverflowItems: true }),
19+
{
20+
overflowActionKeys: ['settings'],
21+
visibleActionKeys: ['timeline', 'download']
22+
}
23+
)
24+
25+
assert.deepEqual(
26+
splitSecondaryActionsByVisibility({ toolbarWidth: 220, hasOverflowItems: true }),
27+
{
28+
overflowActionKeys: ['settings', 'timeline'],
29+
visibleActionKeys: ['download']
30+
}
31+
)
32+
33+
assert.deepEqual(
34+
splitSecondaryActionsByVisibility({ toolbarWidth: 170, hasOverflowItems: true }),
35+
{
36+
overflowActionKeys: ['settings', 'timeline', 'download'],
37+
visibleActionKeys: []
38+
}
39+
)
40+
})
41+
42+
test('controlsLayout may skip directly to a single visible secondary action when the overflow trigger first appears', () => {
43+
assert.deepEqual(
44+
splitSecondaryActionsByVisibility({ toolbarWidth: 210, hasOverflowItems: false }),
45+
{
46+
overflowActionKeys: ['settings', 'timeline'],
47+
visibleActionKeys: ['download']
48+
}
49+
)
50+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export const SECONDARY_ACTION_KEYS = ['settings', 'timeline', 'download']
2+
3+
const ACTION_BUTTON_WIDTH = 46
4+
const TIER_DIVIDER_WIDTH = 12
5+
const COLLAPSE_BUFFER_WIDTH = 40
6+
7+
const getRequiredToolbarWidth = (visibleSecondaryCount, hasOverflowItems) => {
8+
let requiredWidth = ACTION_BUTTON_WIDTH // Save
9+
10+
if (visibleSecondaryCount > 0) {
11+
requiredWidth += (visibleSecondaryCount * ACTION_BUTTON_WIDTH) + TIER_DIVIDER_WIDTH
12+
}
13+
14+
if (hasOverflowItems) {
15+
requiredWidth += ACTION_BUTTON_WIDTH + TIER_DIVIDER_WIDTH
16+
}
17+
18+
return requiredWidth
19+
}
20+
21+
export const getVisibleSecondaryActionCount = ({ toolbarWidth, hasOverflowItems }) => {
22+
if (!Number.isFinite(toolbarWidth) || toolbarWidth <= 0) {
23+
return SECONDARY_ACTION_KEYS.length
24+
}
25+
26+
const effectiveToolbarWidth = Math.max(0, toolbarWidth - COLLAPSE_BUFFER_WIDTH)
27+
28+
for (let visibleCount = SECONDARY_ACTION_KEYS.length; visibleCount >= 0; visibleCount -= 1) {
29+
const usesOverflow = hasOverflowItems || visibleCount < SECONDARY_ACTION_KEYS.length
30+
if (effectiveToolbarWidth >= getRequiredToolbarWidth(visibleCount, usesOverflow)) {
31+
return visibleCount
32+
}
33+
}
34+
35+
return 0
36+
}
37+
38+
export const splitSecondaryActionsByVisibility = ({ toolbarWidth, hasOverflowItems }) => {
39+
const visibleCount = getVisibleSecondaryActionCount({ toolbarWidth, hasOverflowItems })
40+
41+
return {
42+
overflowActionKeys: SECONDARY_ACTION_KEYS.slice(0, SECONDARY_ACTION_KEYS.length - visibleCount),
43+
visibleActionKeys: SECONDARY_ACTION_KEYS.slice(SECONDARY_ACTION_KEYS.length - visibleCount)
44+
}
45+
}

0 commit comments

Comments
 (0)