fix(ui): flush playlist context submenus to parent row (#713)

* fix(ui): place playlist context submenus flush to trigger row

Use left/right/top 100% instead of calc(100% + 4px) so there is no dead
gap when moving the pointer from Add to playlist into the submenu.

* fix(ui): defer closing playlist submenu on trigger mouseleave

Use a short timer and :hover on the trigger row so slow moves across
border/subpixel gaps still reach the nested submenu; cancel timer on
re-enter and when the context menu closes.
This commit is contained in:
cucadmuh
2026-05-15 03:13:40 +03:00
committed by GitHub
parent f275ce4910
commit cfcbbd79e4
11 changed files with 74 additions and 36 deletions
+31 -2
View File
@@ -56,15 +56,41 @@ export default function ContextMenu() {
const [keyboardRating, setKeyboardRating] = useState<{ kind: 'song' | 'album' | 'artist'; id: string; value: number } | null>(null);
const [pendingSubmenuKeyboardFocus, setPendingSubmenuKeyboardFocus] = useState(false);
const playlistSubmenuCloseTimerRef = useRef<number | null>(null);
const cancelPlaylistSubmenuCloseTimer = useCallback(() => {
if (playlistSubmenuCloseTimerRef.current != null) {
window.clearTimeout(playlistSubmenuCloseTimerRef.current);
playlistSubmenuCloseTimerRef.current = null;
}
}, []);
/** Delay close so a slow move across subpixel / border seams still lands on `.context-submenu` (a child of the row). */
const onPlaylistSubmenuTriggerMouseLeave = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
const cur = e.currentTarget;
const next = e.relatedTarget;
if (next instanceof Node && cur.contains(next)) return;
cancelPlaylistSubmenuCloseTimer();
playlistSubmenuCloseTimerRef.current = window.setTimeout(() => {
playlistSubmenuCloseTimerRef.current = null;
if (!cur.isConnected) return;
if (!cur.matches(':hover')) setPlaylistSubmenuOpen(false);
}, 140);
},
[cancelPlaylistSubmenuCloseTimer],
);
useEffect(() => {
if (contextMenu.isOpen) {
cancelPlaylistSubmenuCloseTimer();
setCoords({ x: contextMenu.x, y: contextMenu.y });
setPlaylistSubmenuOpen(false);
setPlaylistSongIds([]);
setKeyboardRating(null);
setPendingSubmenuKeyboardFocus(false);
}
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y]);
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y, cancelPlaylistSubmenuCloseTimer]);
useEffect(() => {
if (contextMenu.isOpen && menuRef.current) {
@@ -84,6 +110,7 @@ export default function ContextMenu() {
previousFocusRef.current = document.activeElement as HTMLElement | null;
return;
}
cancelPlaylistSubmenuCloseTimer();
// Clean up any keyboard focus styling when menu closes
menuRef.current
?.querySelectorAll<HTMLElement>('.context-menu-keyboard-active')
@@ -95,7 +122,7 @@ export default function ContextMenu() {
prev.focus({ preventScroll: true });
});
}
}, [contextMenu.isOpen, closeContextMenu]);
}, [contextMenu.isOpen, closeContextMenu, cancelPlaylistSubmenuCloseTimer]);
const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride } = contextMenu;
@@ -173,6 +200,8 @@ export default function ContextMenu() {
keyboardRating={keyboardRating}
playlistSubmenuOpen={playlistSubmenuOpen}
setPlaylistSubmenuOpen={setPlaylistSubmenuOpen}
cancelPlaylistSubmenuCloseTimer={cancelPlaylistSubmenuCloseTimer}
onPlaylistSubmenuTriggerMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
playlistSongIds={playlistSongIds}
setPlaylistSongIds={setPlaylistSongIds}
orbitRole={orbitRole}