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;