From cfcbbd79e4355b3e1e850720f313c90226373adc Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 15 May 2026 03:13:40 +0300 Subject: [PATCH] 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. --- src/components/ContextMenu.tsx | 33 +++++++++++++++++-- .../contextMenu/AddToPlaylistSubmenu.tsx | 8 +++-- .../contextMenu/AlbumContextItems.tsx | 11 ++++--- .../contextMenu/ArtistContextItems.tsx | 11 ++++--- .../MultiAlbumToPlaylistSubmenu.tsx | 4 +-- .../MultiArtistToPlaylistSubmenu.tsx | 4 +-- .../contextMenu/PlaylistContextItems.tsx | 11 ++++--- .../PlaylistToPlaylistSubmenus.tsx | 8 ++--- .../contextMenu/QueueItemContextItems.tsx | 7 ++-- .../contextMenu/SongContextItems.tsx | 11 ++++--- .../contextMenu/contextMenuItemTypes.ts | 2 ++ 11 files changed, 74 insertions(+), 36 deletions(-) diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 8b44c0de..9bec356e 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -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(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) => { + 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('.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} diff --git a/src/components/contextMenu/AddToPlaylistSubmenu.tsx b/src/components/contextMenu/AddToPlaylistSubmenu.tsx index a26897a1..52fd9165 100644 --- a/src/components/contextMenu/AddToPlaylistSubmenu.tsx +++ b/src/components/contextMenu/AddToPlaylistSubmenu.tsx @@ -102,11 +102,13 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: P onDone(); }; + // Flush to the parent edge (left/right/top 100%). Actual “hole” cases are handled + // in ContextMenu via a short delayed mouseleave + :hover check on the trigger row. const subStyle: React.CSSProperties = dropDown - ? { top: 'calc(100% + 4px)', left: 0, right: 'auto' } + ? { top: '100%', left: 0, right: 'auto' } : flipLeft - ? { right: 'calc(100% + 4px)', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } - : { left: 'calc(100% + 4px)', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; + ? { right: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } + : { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; return (
diff --git a/src/components/contextMenu/AlbumContextItems.tsx b/src/components/contextMenu/AlbumContextItems.tsx index 4d8995b0..541011bb 100644 --- a/src/components/contextMenu/AlbumContextItems.tsx +++ b/src/components/contextMenu/AlbumContextItems.tsx @@ -17,7 +17,8 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) { playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, - playlistSubmenuOpen, setPlaylistSubmenuOpen, playlistSongIds, setPlaylistSongIds, + playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, + playlistSongIds, setPlaylistSongIds, orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, applySongRating, applyAlbumRating, applyArtistRating, handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, @@ -89,8 +90,8 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
{ setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={() => setPlaylistSubmenuOpen(false)} + onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} > {t('contextMenu.addToPlaylist')} @@ -130,8 +131,8 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
{ setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={() => setPlaylistSubmenuOpen(false)} + onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} > {t('contextMenu.addToPlaylist')} diff --git a/src/components/contextMenu/ArtistContextItems.tsx b/src/components/contextMenu/ArtistContextItems.tsx index 290acc87..7019f53c 100644 --- a/src/components/contextMenu/ArtistContextItems.tsx +++ b/src/components/contextMenu/ArtistContextItems.tsx @@ -15,7 +15,8 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) { playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, - playlistSubmenuOpen, setPlaylistSubmenuOpen, playlistSongIds, setPlaylistSongIds, + playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, + playlistSongIds, setPlaylistSongIds, orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, applySongRating, applyAlbumRating, applyArtistRating, handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, @@ -37,8 +38,8 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
{ setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={() => setPlaylistSubmenuOpen(false)} + onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} > {t('contextMenu.addToPlaylist')} @@ -99,8 +100,8 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
{ setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={() => setPlaylistSubmenuOpen(false)} + onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} > {t('contextMenu.addToPlaylist')} diff --git a/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx b/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx index 448cbcde..4b812e94 100644 --- a/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx +++ b/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx @@ -129,8 +129,8 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig }; const subStyle: React.CSSProperties = flipLeft - ? { right: 'calc(100% + 4px)', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } - : { left: 'calc(100% + 4px)', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; + ? { right: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } + : { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; return (
diff --git a/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx b/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx index 3342cb4f..931e1690 100644 --- a/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx +++ b/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx @@ -139,8 +139,8 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr }; const subStyle: React.CSSProperties = flipLeft - ? { right: 'calc(100% + 4px)', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } - : { left: 'calc(100% + 4px)', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; + ? { right: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } + : { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; return (
diff --git a/src/components/contextMenu/PlaylistContextItems.tsx b/src/components/contextMenu/PlaylistContextItems.tsx index bc172779..727d2cc0 100644 --- a/src/components/contextMenu/PlaylistContextItems.tsx +++ b/src/components/contextMenu/PlaylistContextItems.tsx @@ -13,7 +13,8 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) { playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, - playlistSubmenuOpen, setPlaylistSubmenuOpen, playlistSongIds, setPlaylistSongIds, + playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, + playlistSongIds, setPlaylistSongIds, orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, applySongRating, applyAlbumRating, applyArtistRating, handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, @@ -35,8 +36,8 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
{ setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={() => setPlaylistSubmenuOpen(false)} + onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} > {t('contextMenu.addToPlaylist')} @@ -79,8 +80,8 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
{ setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={() => setPlaylistSubmenuOpen(false)} + onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} > {t('contextMenu.addToPlaylist')} diff --git a/src/components/contextMenu/PlaylistToPlaylistSubmenus.tsx b/src/components/contextMenu/PlaylistToPlaylistSubmenus.tsx index 5877f71c..cc514fca 100644 --- a/src/components/contextMenu/PlaylistToPlaylistSubmenus.tsx +++ b/src/components/contextMenu/PlaylistToPlaylistSubmenus.tsx @@ -98,8 +98,8 @@ export function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: }; const subStyle: React.CSSProperties = flipLeft - ? { right: 'calc(100% + 4px)', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } - : { left: 'calc(100% + 4px)', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; + ? { right: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } + : { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; return (
@@ -255,8 +255,8 @@ export function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: }; const subStyle: React.CSSProperties = flipLeft - ? { right: 'calc(100% + 4px)', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } - : { left: 'calc(100% + 4px)', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; + ? { right: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } + : { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; return (
diff --git a/src/components/contextMenu/QueueItemContextItems.tsx b/src/components/contextMenu/QueueItemContextItems.tsx index e3138844..ec2817aa 100644 --- a/src/components/contextMenu/QueueItemContextItems.tsx +++ b/src/components/contextMenu/QueueItemContextItems.tsx @@ -16,7 +16,8 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) { playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, - playlistSubmenuOpen, setPlaylistSubmenuOpen, playlistSongIds, setPlaylistSongIds, + playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, + playlistSongIds, setPlaylistSongIds, orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, applySongRating, applyAlbumRating, applyArtistRating, handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, @@ -42,8 +43,8 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
{ setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={() => setPlaylistSubmenuOpen(false)} + onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} > {t('contextMenu.addToPlaylist')} diff --git a/src/components/contextMenu/SongContextItems.tsx b/src/components/contextMenu/SongContextItems.tsx index dcac8382..cf5feb63 100644 --- a/src/components/contextMenu/SongContextItems.tsx +++ b/src/components/contextMenu/SongContextItems.tsx @@ -21,7 +21,8 @@ export default function SongContextItems(props: ContextMenuItemsProps) { playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, - playlistSubmenuOpen, setPlaylistSubmenuOpen, playlistSongIds, setPlaylistSongIds, + playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, + playlistSongIds, setPlaylistSongIds, orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, applySongRating, applyAlbumRating, applyArtistRating, handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, @@ -80,8 +81,8 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
{ setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={() => setPlaylistSubmenuOpen(false)} + onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} > {t('contextMenu.addToPlaylist')} @@ -234,8 +235,8 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
{ setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={() => setPlaylistSubmenuOpen(false)} + onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} > {t('contextMenu.addToPlaylist')} diff --git a/src/components/contextMenu/contextMenuItemTypes.ts b/src/components/contextMenu/contextMenuItemTypes.ts index 88865b9f..16fa52a8 100644 --- a/src/components/contextMenu/contextMenuItemTypes.ts +++ b/src/components/contextMenu/contextMenuItemTypes.ts @@ -35,6 +35,8 @@ export interface ContextMenuItemsProps { keyboardRating: KeyboardRating | null; playlistSubmenuOpen: boolean; setPlaylistSubmenuOpen: React.Dispatch>; + cancelPlaylistSubmenuCloseTimer: () => void; + onPlaylistSubmenuTriggerMouseLeave: (e: React.MouseEvent) => void; playlistSongIds: string[]; setPlaylistSongIds: React.Dispatch>; orbitRole: 'host' | 'guest' | null;