Skip to content

Commit a107a4d

Browse files
committed
frontend: Improve activity snap menu UX with delayed hover and non-modal popup
Signed-off-by: kahirokunn <okinakahiro@gmail.com>
1 parent e180cb2 commit a107a4d

File tree

2 files changed

+219
-81
lines changed

2 files changed

+219
-81
lines changed

frontend/src/components/activity/Activity.tsx

Lines changed: 215 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@ import {
1919
alpha,
2020
Box,
2121
Button,
22+
ClickAwayListener,
2223
IconButton,
2324
ListItemIcon,
2425
ListItemText,
25-
Menu,
2626
MenuItem,
27+
MenuList,
28+
Paper,
29+
Popper,
2730
Tooltip,
2831
Typography,
2932
useMediaQuery,
@@ -140,12 +143,28 @@ export function SingleActivityRenderer({
140143
const theme = useTheme();
141144
const isSmallScreen = useMediaQuery(theme.breakpoints.down('lg'));
142145
const [snapMenuAnchor, setSnapMenuAnchor] = useState<HTMLElement | null>(null);
146+
const [snapMenuOpenReason, setSnapMenuOpenReason] = useState<'hover' | 'click' | null>(null);
147+
const openTimerRef = useRef<NodeJS.Timeout | null>(null);
148+
const closeTimerRef = useRef<NodeJS.Timeout | null>(null);
149+
const menuListRef = useRef<HTMLUListElement>(null);
143150
const isSnapMenuOpen = Boolean(snapMenuAnchor);
144151

145152
useEffect(() => {
146153
containerElementRef.current = document.getElementById('main');
147154
}, []);
148155

156+
// Cleanup timers on unmount
157+
useEffect(() => {
158+
return () => {
159+
if (openTimerRef.current) {
160+
clearTimeout(openTimerRef.current);
161+
}
162+
if (closeTimerRef.current) {
163+
clearTimeout(closeTimerRef.current);
164+
}
165+
};
166+
}, []);
167+
149168
// Styles of different activity locations
150169
const locationStyles = {
151170
full: {
@@ -408,93 +427,208 @@ export function SingleActivityRenderer({
408427
<IconButton
409428
size="small"
410429
title={t('Window')}
411-
onMouseEnter={event => setSnapMenuAnchor(event.currentTarget)}
412-
onClick={event => setSnapMenuAnchor(event.currentTarget)}
430+
aria-haspopup="menu"
431+
aria-expanded={isSnapMenuOpen}
432+
aria-controls={isSnapMenuOpen ? 'snap-menu' : undefined}
433+
onMouseEnter={event => {
434+
// Clear any existing close timer
435+
if (closeTimerRef.current) {
436+
clearTimeout(closeTimerRef.current);
437+
closeTimerRef.current = null;
438+
}
439+
// Set 350ms timer to open on hover
440+
if (openTimerRef.current) {
441+
clearTimeout(openTimerRef.current);
442+
}
443+
const target = event.currentTarget;
444+
openTimerRef.current = setTimeout(() => {
445+
setSnapMenuAnchor(target);
446+
setSnapMenuOpenReason('hover');
447+
openTimerRef.current = null;
448+
}, 350);
449+
}}
450+
onMouseLeave={() => {
451+
// Cancel open timer if pointer leaves before 350ms
452+
if (openTimerRef.current) {
453+
clearTimeout(openTimerRef.current);
454+
openTimerRef.current = null;
455+
}
456+
// If menu is open due to hover, start close timer
457+
if (snapMenuOpenReason === 'hover' && isSnapMenuOpen) {
458+
if (closeTimerRef.current) {
459+
clearTimeout(closeTimerRef.current);
460+
}
461+
closeTimerRef.current = setTimeout(() => {
462+
setSnapMenuAnchor(null);
463+
setSnapMenuOpenReason(null);
464+
closeTimerRef.current = null;
465+
}, 150);
466+
}
467+
}}
468+
onClick={event => {
469+
// Clear any timers
470+
if (openTimerRef.current) {
471+
clearTimeout(openTimerRef.current);
472+
openTimerRef.current = null;
473+
}
474+
if (closeTimerRef.current) {
475+
clearTimeout(closeTimerRef.current);
476+
closeTimerRef.current = null;
477+
}
478+
// Toggle menu on click
479+
if (isSnapMenuOpen && snapMenuOpenReason === 'click') {
480+
setSnapMenuAnchor(null);
481+
setSnapMenuOpenReason(null);
482+
} else {
483+
setSnapMenuAnchor(event.currentTarget);
484+
setSnapMenuOpenReason('click');
485+
}
486+
}}
487+
onKeyDown={event => {
488+
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
489+
event.preventDefault();
490+
if (openTimerRef.current) {
491+
clearTimeout(openTimerRef.current);
492+
openTimerRef.current = null;
493+
}
494+
if (!isSnapMenuOpen) {
495+
setSnapMenuAnchor(event.currentTarget as HTMLElement);
496+
setSnapMenuOpenReason('click');
497+
}
498+
}
499+
}}
413500
>
414501
<Icon icon="mdi:dock-window" />
415502
</IconButton>
416-
<Menu
417-
anchorEl={snapMenuAnchor}
503+
<Popper
504+
id="snap-menu"
418505
open={isSnapMenuOpen}
419-
onClose={() => setSnapMenuAnchor(null)}
420-
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
421-
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
422-
MenuListProps={{
423-
onMouseLeave: () => setSnapMenuAnchor(null),
424-
'aria-label': t('Window'),
425-
}}
506+
anchorEl={snapMenuAnchor}
507+
placement="bottom-end"
508+
sx={{ zIndex: theme.zIndex.modal }}
426509
>
427-
<MenuItem
428-
selected={location === 'full'}
429-
aria-label={t('Fullscreen')}
430-
title={t('Fullscreen')}
431-
onClick={() => {
432-
Activity.update(id, { location: 'full' });
433-
setSnapMenuAnchor(null);
434-
}}
435-
>
436-
<ListItemIcon>
437-
<Icon icon="mdi:fullscreen" />
438-
</ListItemIcon>
439-
<ListItemText>{t('Fullscreen')}</ListItemText>
440-
</MenuItem>
441-
<MenuItem
442-
selected={location === 'split-left'}
443-
aria-label={t('Snap Left')}
444-
title={t('Snap Left')}
445-
onClick={() => {
446-
Activity.update(id, { location: 'split-left' });
447-
setSnapMenuAnchor(null);
448-
}}
449-
>
450-
<ListItemIcon>
451-
<Icon icon="mdi:dock-left" />
452-
</ListItemIcon>
453-
<ListItemText>{t('Snap Left')}</ListItemText>
454-
</MenuItem>
455-
<MenuItem
456-
selected={location === 'split-right'}
457-
aria-label={t('Snap Right')}
458-
title={t('Snap Right')}
459-
onClick={() => {
460-
Activity.update(id, { location: 'split-right' });
461-
setSnapMenuAnchor(null);
462-
}}
463-
>
464-
<ListItemIcon>
465-
<Icon icon="mdi:dock-right" />
466-
</ListItemIcon>
467-
<ListItemText>{t('Snap Right')}</ListItemText>
468-
</MenuItem>
469-
<MenuItem
470-
selected={location === 'split-top'}
471-
aria-label={t('Snap Top')}
472-
title={t('Snap Top')}
473-
onClick={() => {
474-
Activity.update(id, { location: 'split-top' });
475-
setSnapMenuAnchor(null);
476-
}}
477-
>
478-
<ListItemIcon>
479-
<Icon icon="mdi:dock-top" />
480-
</ListItemIcon>
481-
<ListItemText>{t('Snap Top')}</ListItemText>
482-
</MenuItem>
483-
<MenuItem
484-
selected={location === 'split-bottom'}
485-
aria-label={t('Snap Bottom')}
486-
title={t('Snap Bottom')}
487-
onClick={() => {
488-
Activity.update(id, { location: 'split-bottom' });
489-
setSnapMenuAnchor(null);
510+
<ClickAwayListener
511+
onClickAway={() => {
512+
if (snapMenuOpenReason === 'click') {
513+
setSnapMenuAnchor(null);
514+
setSnapMenuOpenReason(null);
515+
}
490516
}}
491517
>
492-
<ListItemIcon>
493-
<Icon icon="mdi:dock-bottom" />
494-
</ListItemIcon>
495-
<ListItemText>{t('Snap Bottom')}</ListItemText>
496-
</MenuItem>
497-
</Menu>
518+
<Paper
519+
elevation={8}
520+
onMouseEnter={() => {
521+
// Cancel close timer when entering menu (for hover-open case)
522+
if (closeTimerRef.current && snapMenuOpenReason === 'hover') {
523+
clearTimeout(closeTimerRef.current);
524+
closeTimerRef.current = null;
525+
}
526+
}}
527+
onMouseLeave={() => {
528+
// Start close timer when leaving menu (for hover-open case)
529+
if (snapMenuOpenReason === 'hover') {
530+
if (closeTimerRef.current) {
531+
clearTimeout(closeTimerRef.current);
532+
}
533+
closeTimerRef.current = setTimeout(() => {
534+
setSnapMenuAnchor(null);
535+
setSnapMenuOpenReason(null);
536+
closeTimerRef.current = null;
537+
}, 150);
538+
}
539+
}}
540+
onKeyDown={event => {
541+
if (event.key === 'Escape') {
542+
setSnapMenuAnchor(null);
543+
setSnapMenuOpenReason(null);
544+
snapMenuAnchor?.focus();
545+
}
546+
}}
547+
>
548+
<MenuList
549+
ref={menuListRef}
550+
aria-label={t('Window')}
551+
autoFocusItem={isSnapMenuOpen}
552+
>
553+
<MenuItem
554+
selected={location === 'full'}
555+
aria-label={t('Fullscreen')}
556+
title={t('Fullscreen')}
557+
onClick={() => {
558+
Activity.update(id, { location: 'full' });
559+
setSnapMenuAnchor(null);
560+
setSnapMenuOpenReason(null);
561+
}}
562+
>
563+
<ListItemIcon>
564+
<Icon icon="mdi:fullscreen" />
565+
</ListItemIcon>
566+
<ListItemText>{t('Fullscreen')}</ListItemText>
567+
</MenuItem>
568+
<MenuItem
569+
selected={location === 'split-left'}
570+
aria-label={t('Snap Left')}
571+
title={t('Snap Left')}
572+
onClick={() => {
573+
Activity.update(id, { location: 'split-left' });
574+
setSnapMenuAnchor(null);
575+
setSnapMenuOpenReason(null);
576+
}}
577+
>
578+
<ListItemIcon>
579+
<Icon icon="mdi:dock-left" />
580+
</ListItemIcon>
581+
<ListItemText>{t('Snap Left')}</ListItemText>
582+
</MenuItem>
583+
<MenuItem
584+
selected={location === 'split-right'}
585+
aria-label={t('Snap Right')}
586+
title={t('Snap Right')}
587+
onClick={() => {
588+
Activity.update(id, { location: 'split-right' });
589+
setSnapMenuAnchor(null);
590+
setSnapMenuOpenReason(null);
591+
}}
592+
>
593+
<ListItemIcon>
594+
<Icon icon="mdi:dock-right" />
595+
</ListItemIcon>
596+
<ListItemText>{t('Snap Right')}</ListItemText>
597+
</MenuItem>
598+
<MenuItem
599+
selected={location === 'split-top'}
600+
aria-label={t('Snap Top')}
601+
title={t('Snap Top')}
602+
onClick={() => {
603+
Activity.update(id, { location: 'split-top' });
604+
setSnapMenuAnchor(null);
605+
setSnapMenuOpenReason(null);
606+
}}
607+
>
608+
<ListItemIcon>
609+
<Icon icon="mdi:dock-top" />
610+
</ListItemIcon>
611+
<ListItemText>{t('Snap Top')}</ListItemText>
612+
</MenuItem>
613+
<MenuItem
614+
selected={location === 'split-bottom'}
615+
aria-label={t('Snap Bottom')}
616+
title={t('Snap Bottom')}
617+
onClick={() => {
618+
Activity.update(id, { location: 'split-bottom' });
619+
setSnapMenuAnchor(null);
620+
setSnapMenuOpenReason(null);
621+
}}
622+
>
623+
<ListItemIcon>
624+
<Icon icon="mdi:dock-bottom" />
625+
</ListItemIcon>
626+
<ListItemText>{t('Snap Bottom')}</ListItemText>
627+
</MenuItem>
628+
</MenuList>
629+
</Paper>
630+
</ClickAwayListener>
631+
</Popper>
498632
<IconButton
499633
onClick={() => {
500634
Activity.update(id, { minimized: true });

frontend/src/components/activity/__snapshots__/Activity.Basic.stories.storyshot

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
class="MuiBox-root css-1po99kh"
3131
/>
3232
<button
33+
aria-expanded="false"
34+
aria-haspopup="menu"
3335
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-hvz71z-MuiButtonBase-root-MuiIconButton-root"
3436
tabindex="0"
3537
title="Window"
@@ -87,6 +89,8 @@
8789
class="MuiBox-root css-1po99kh"
8890
/>
8991
<button
92+
aria-expanded="false"
93+
aria-haspopup="menu"
9094
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-hvz71z-MuiButtonBase-root-MuiIconButton-root"
9195
tabindex="0"
9296
title="Window"

0 commit comments

Comments
 (0)