Skip to content

Commit f1b7a13

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 2360066 commit f1b7a13

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