feat(ui): implement global FloatingMenu system for menu exclusivity

- Add floatingMenuStore.ts with global menu state management
- Only one floating menu can be open at a time across the entire app
- Standardize inactivity timeout to 2 seconds for all menus

Updated components:
- TrackMenu: integrate with global store, reduce timeout from 5s to 2s
- AlbumMenu: integrate with global store, add inactivity timeout
- Sidebar: integrate dropdown, context menu, and folder popover
- PlaylistManagerView: integrate filter and sort dropdowns
- Dropdown.svelte: integrate generic dropdown component

The global store ensures that opening any menu (context menu,
dropdown, popover) automatically closes all other open menus.
This commit is contained in:
vicrodh committed 2026-01-27 00:10:13 -06:00
1 parent 19756092a4
commit f84c27b5ee
7 files changed
+520 -42

No files matched your search

+74 -19
View File
@@ -12,6 +12,13 @@
Link,
RefreshCw
} from 'lucide-svelte';
import {
openMenu as openGlobalMenu,
closeMenu as closeGlobalMenu,
subscribe as subscribeGlobal,
getActiveMenuId,
MENU_INACTIVITY_TIMEOUT
} from '$lib/stores/floatingMenuStore';
interface Props {
onPlayNext?: () => void;
@@ -53,7 +60,11 @@
let submenuStyle = $state('');
let downloadSubmenuStyle = $state('');
let portalTarget: HTMLElement | null = null;
const menuId = Symbol('album-menu');
let isHoveringMenu = $state(false);
// Unique menu ID for global store
let menuIdCounter = 0;
const menuId = `album-menu-${++menuIdCounter}-${Date.now()}`;
const hasQueue = $derived(!!(onPlayNext || onPlayLater));
const hasLibrary = $derived(!!onAddToPlaylist);
@@ -66,6 +77,16 @@
shareOpen = false;
downloadOpen = false;
onOpenChange?.(false);
closeGlobalMenu(menuId);
}
function doOpenMenu() {
openGlobalMenu(menuId);
isOpen = true;
shareOpen = false;
downloadOpen = false;
onOpenChange?.(true);
setMenuPosition();
}
function handleClickOutside(event: MouseEvent) {
@@ -79,16 +100,17 @@
onMount(() => {
portalTarget = document.body;
const handleOtherOpen = (event: Event) => {
const detail = (event as CustomEvent<symbol>).detail;
if (detail !== menuId && isOpen) {
closeMenu();
// Subscribe to global floating menu store
const unsubscribe = subscribeGlobal(() => {
const activeId = getActiveMenuId();
if (activeId !== null && activeId !== menuId && isOpen) {
isOpen = false;
shareOpen = false;
downloadOpen = false;
onOpenChange?.(false);
}
};
window.addEventListener('qbz-album-menu-open', handleOtherOpen as EventListener);
return () => {
window.removeEventListener('qbz-album-menu-open', handleOtherOpen as EventListener);
};
});
return unsubscribe;
});
async function setMenuPosition(retries = 2) {
@@ -210,10 +232,40 @@
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleScroll, true);
// Auto-close after inactivity
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (isOpen && !isHoveringMenu) closeMenu();
}, MENU_INACTIVITY_TIMEOUT);
};
const cancelIdleClose = () => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
};
if (!isHoveringMenu) scheduleIdleClose();
const onAnyActivity = () => {
if (!isHoveringMenu) scheduleIdleClose();
};
window.addEventListener('pointermove', onAnyActivity, true);
window.addEventListener('wheel', onAnyActivity, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('pointermove', onAnyActivity, true);
window.removeEventListener('wheel', onAnyActivity, true);
cancelIdleClose();
};
}
});
@@ -231,14 +283,10 @@
bind:this={triggerRef}
onclick={(e) => {
e.stopPropagation();
const nextOpen = !isOpen;
isOpen = nextOpen;
shareOpen = false;
downloadOpen = false;
onOpenChange?.(nextOpen);
if (nextOpen) {
window.dispatchEvent(new CustomEvent('qbz-album-menu-open', { detail: menuId }));
setMenuPosition();
if (isOpen) {
closeMenu();
} else {
doOpenMenu();
}
}}
aria-label="Album actions"
@@ -248,7 +296,14 @@
{#if isOpen && portalTarget}
<Portal target={portalTarget}>
<div class="menu" bind:this={menuEl} style={menuStyle} onmousedown={(e) => e.stopPropagation()}>
<div
class="menu"
bind:this={menuEl}
style={menuStyle}
onmousedown={(e) => e.stopPropagation()}
onmouseenter={() => isHoveringMenu = true}
onmouseleave={() => isHoveringMenu = false}
>
{#if hasQueue}
{#if onPlayNext}
<button class="menu-item" onclick={() => handleAction(onPlayNext)}>
+67 -6
View File
@@ -1,5 +1,12 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte';
import {
openMenu as openGlobalMenu,
closeMenu as closeGlobalMenu,
subscribe as subscribeFloatingMenu,
getActiveMenuId,
MENU_INACTIVITY_TIMEOUT
} from '$lib/stores/floatingMenuStore';
interface Props {
value: string;
@@ -13,37 +20,91 @@
let { value, options, onchange, wide = false, expandLeft = false, compact = false }: Props = $props();
let isOpen = $state(false);
let isHovering = $state(false);
let dropdownRef: HTMLDivElement;
// Unique ID for this dropdown instance
const menuId = `dropdown-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
function handleClickOutside(event: MouseEvent) {
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
isOpen = false;
closeDropdown();
}
}
function openDropdown() {
openGlobalMenu(menuId);
isOpen = true;
}
function closeDropdown() {
isOpen = false;
closeGlobalMenu(menuId);
}
// Subscribe to global floating menu store
$effect(() => {
const unsubscribe = subscribeFloatingMenu(() => {
const activeId = getActiveMenuId();
if (activeId !== null && activeId !== menuId && isOpen) {
isOpen = false;
}
});
return unsubscribe;
});
$effect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
// Inactivity timeout
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (isOpen && !isHovering) closeDropdown();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHovering) scheduleIdleClose();
const onActivity = () => {
if (!isHovering) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
</script>
<div class="dropdown" class:wide bind:this={dropdownRef}>
<button class="trigger" onclick={() => (isOpen = !isOpen)}>
<button class="trigger" onclick={() => isOpen ? closeDropdown() : openDropdown()}>
<span class="value-text">{value}</span>
<ChevronDown size={16} class="chevron" />
</button>
{#if isOpen}
<div class="menu" class:expand-left={expandLeft} class:compact>
<div
class="menu"
class:expand-left={expandLeft}
class:compact
onmouseenter={() => isHovering = true}
onmouseleave={() => isHovering = false}
>
{#each options as option}
<button
class="option"
class:selected={option === value}
onclick={() => {
onchange(option);
isOpen = false;
closeDropdown();
}}
title={option}
>
@@ -170,7 +231,7 @@
background-color: rgba(66, 133, 244, 0.15);
color: var(--text-primary);
}
[data-theme="light"] .option.selected,
[data-theme="warm"] .option.selected {
background-color: rgba(var(--accent-primary), 0.15);
+141 -1
View File
@@ -24,6 +24,13 @@
movePlaylistToFolder,
type PlaylistFolder
} from '$lib/stores/playlistFoldersStore';
import {
openMenu as openGlobalMenu,
closeMenu as closeGlobalMenu,
subscribe as subscribeFloatingMenu,
getActiveMenuId,
MENU_INACTIVITY_TIMEOUT
} from '$lib/stores/floatingMenuStore';
interface Playlist {
id: number;
@@ -137,6 +144,7 @@
});
function showFolderPopover(event: MouseEvent, folder: PlaylistFolder) {
openGlobalMenu(SIDEBAR_FOLDER_POPOVER_ID);
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
folderPopover = {
visible: true,
@@ -149,6 +157,7 @@
function closeFolderPopover() {
folderPopover = { ...folderPopover, visible: false, folderId: null };
closeGlobalMenu(SIDEBAR_FOLDER_POPOVER_ID);
}
// Filtered folders for context menu
@@ -175,6 +184,14 @@
let submenuEl: HTMLDivElement | null = null;
let menuStyle = $state('');
let submenuStyle = $state('');
let isHoveringDropdown = $state(false);
let isHoveringContextMenu = $state(false);
let isHoveringFolderPopover = $state(false);
// Unique IDs for global floating menu store
const SIDEBAR_DROPDOWN_ID = 'sidebar-dropdown';
const SIDEBAR_CONTEXT_MENU_ID = 'sidebar-context-menu';
const SIDEBAR_FOLDER_POPOVER_ID = 'sidebar-folder-popover';
function openSubmenu() {
if (submenuCloseTimeout) {
@@ -420,6 +437,7 @@
clearTimeout(submenuCloseTimeout);
submenuCloseTimeout = null;
}
closeGlobalMenu(SIDEBAR_DROPDOWN_ID);
}
function handleClickOutside(event: MouseEvent) {
@@ -481,6 +499,7 @@
if (menuOpen) {
closeMenu();
} else {
openGlobalMenu(SIDEBAR_DROPDOWN_ID);
menuOpen = true;
await setMenuPosition();
document.addEventListener('click', handleClickOutside);
@@ -540,6 +559,114 @@
// Check if any playlists exist in root (no folder)
let rootPlaylists = $derived(getPlaylistsInFolder(null));
// Subscribe to global floating menu store
$effect(() => {
const unsubscribe = subscribeFloatingMenu(() => {
const activeId = getActiveMenuId();
// Close dropdown if another menu is active
if (activeId !== null && activeId !== SIDEBAR_DROPDOWN_ID && menuOpen) {
menuOpen = false;
sortSubmenuOpen = false;
if (submenuCloseTimeout) {
clearTimeout(submenuCloseTimeout);
submenuCloseTimeout = null;
}
document.removeEventListener('click', handleClickOutside);
}
// Close context menu if another menu is active
if (activeId !== null && activeId !== SIDEBAR_CONTEXT_MENU_ID && contextMenu.visible) {
contextMenu = { ...contextMenu, visible: false };
contextMenuSearch = '';
}
// Close folder popover if another menu is active
if (activeId !== null && activeId !== SIDEBAR_FOLDER_POPOVER_ID && folderPopover.visible) {
folderPopover = { ...folderPopover, visible: false, folderId: null };
}
});
return unsubscribe;
});
// Inactivity timeout for dropdown menu
$effect(() => {
if (menuOpen) {
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (menuOpen && !isHoveringDropdown) closeMenu();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHoveringDropdown) scheduleIdleClose();
const onActivity = () => {
if (!isHoveringDropdown) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
// Inactivity timeout for context menu
$effect(() => {
if (contextMenu.visible) {
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (contextMenu.visible && !isHoveringContextMenu) closeContextMenu();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHoveringContextMenu) scheduleIdleClose();
const onActivity = () => {
if (!isHoveringContextMenu) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
// Inactivity timeout for folder popover
$effect(() => {
if (folderPopover.visible) {
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (folderPopover.visible && !isHoveringFolderPopover) closeFolderPopover();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHoveringFolderPopover) scheduleIdleClose();
const onActivity = () => {
if (!isHoveringFolderPopover) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
$effect(() => {
if (!menuOpen) {
document.removeEventListener('click', handleClickOutside);
@@ -680,6 +807,8 @@
e.preventDefault();
e.stopPropagation();
openGlobalMenu(SIDEBAR_CONTEXT_MENU_ID);
// Get the current folder_id from settings
const settings = playlistSettings.get(playlist.id);
const currentFolderId = folderId ?? settings?.folder_id ?? null;
@@ -696,6 +825,7 @@
function closeContextMenu() {
contextMenu = { ...contextMenu, visible: false };
contextMenuSearch = '';
closeGlobalMenu(SIDEBAR_CONTEXT_MENU_ID);
}
async function handleMoveToFolder(folderId: string | null) {
@@ -812,7 +942,13 @@
<!-- Dropdown Menu -->
{#if menuOpen}
<div class="dropdown-menu" bind:this={menuEl} style={menuStyle}>
<div
class="dropdown-menu"
bind:this={menuEl}
style={menuStyle}
onmouseenter={() => isHoveringDropdown = true}
onmouseleave={() => isHoveringDropdown = false}
>
<!-- Sort by submenu trigger -->
<div
class="menu-item has-submenu"
@@ -1038,6 +1174,8 @@
class:has-search={showSearch}
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
onclick={(e) => e.stopPropagation()}
onmouseenter={() => isHoveringContextMenu = true}
onmouseleave={() => isHoveringContextMenu = false}
role="menu"
>
{#if availableFolders.length > 0}
@@ -1095,6 +1233,8 @@
class="folder-popover"
style="left: {folderPopover.x}px; top: {folderPopover.y}px;"
onclick={(e) => e.stopPropagation()}
onmouseenter={() => isHoveringFolderPopover = true}
onmouseleave={() => isHoveringFolderPopover = false}
role="menu"
>
<div class="folder-popover-header">
+2 -1
View File
@@ -25,6 +25,7 @@
setActiveTrackMenuId,
subscribeActiveTrackMenuId
} from '$lib/stores/activeTrackMenu';
import { MENU_INACTIVITY_TIMEOUT } from '$lib/stores/floatingMenuStore';
interface Props {
onPlayNow?: () => void;
@@ -265,7 +266,7 @@
// Auto-close after inactivity when the cursor is not hovering any menu/submenu.
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const idleMs = 5_000;
const idleMs = MENU_INACTIVITY_TIMEOUT;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
@@ -24,6 +24,13 @@
movePlaylistToFolder,
type PlaylistFolder
} from '$lib/stores/playlistFoldersStore';
import {
openMenu as openGlobalMenu,
closeMenu as closeGlobalMenu,
subscribe as subscribeFloatingMenu,
getActiveMenuId,
MENU_INACTIVITY_TIMEOUT
} from '$lib/stores/floatingMenuStore';
interface Playlist {
id: number;
@@ -91,6 +98,12 @@
// Dropdown state
let showFilterMenu = $state(false);
let showSortMenu = $state(false);
let isHoveringFilterMenu = $state(false);
let isHoveringSortMenu = $state(false);
// Unique IDs for global floating menu store
const PM_FILTER_MENU_ID = 'playlist-manager-filter';
const PM_SORT_MENU_ID = 'playlist-manager-sort';
// Edit modal state
let editModalOpen = $state(false);
@@ -117,6 +130,85 @@
$effect(() => { localStorage.setItem('qbz-pm-sort', sort); });
$effect(() => { localStorage.setItem('qbz-pm-view', viewMode); });
// Helper functions for closing menus with global store
function closeFilterMenu() {
showFilterMenu = false;
closeGlobalMenu(PM_FILTER_MENU_ID);
}
function closeSortMenu() {
showSortMenu = false;
closeGlobalMenu(PM_SORT_MENU_ID);
}
// Subscribe to global floating menu store
$effect(() => {
const unsubscribe = subscribeFloatingMenu(() => {
const activeId = getActiveMenuId();
if (activeId !== null && activeId !== PM_FILTER_MENU_ID && showFilterMenu) {
showFilterMenu = false;
}
if (activeId !== null && activeId !== PM_SORT_MENU_ID && showSortMenu) {
showSortMenu = false;
}
});
return unsubscribe;
});
// Inactivity timeout for filter menu
$effect(() => {
if (showFilterMenu) {
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (showFilterMenu && !isHoveringFilterMenu) closeFilterMenu();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHoveringFilterMenu) scheduleIdleClose();
const onActivity = () => {
if (!isHoveringFilterMenu) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
// Inactivity timeout for sort menu
$effect(() => {
if (showSortMenu) {
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (showSortMenu && !isHoveringSortMenu) closeSortMenu();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHoveringSortMenu) scheduleIdleClose();
const onActivity = () => {
if (!isHoveringSortMenu) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
// Helper to get local content status for a playlist (calculated from actual data)
function getLocalContentStatus(playlistId: number): LocalContentStatus {
const playlist = playlists.find(p => p.id === playlistId);
@@ -699,7 +791,16 @@
<!-- Filter dropdown -->
<div class="dropdown-container">
<button class="control-btn" onclick={() => { showFilterMenu = !showFilterMenu; showSortMenu = false; }}>
<button class="control-btn" onclick={() => {
if (showFilterMenu) {
showFilterMenu = false;
closeGlobalMenu(PM_FILTER_MENU_ID);
} else {
showSortMenu = false;
openGlobalMenu(PM_FILTER_MENU_ID);
showFilterMenu = true;
}
}}>
{#if filter === 'hidden'}
<EyeOff size={16} />
{:else if filter === 'offline_unavailable'}
@@ -716,26 +817,30 @@
</span>
</button>
{#if showFilterMenu}
<div class="dropdown-menu">
<div
class="dropdown-menu"
onmouseenter={() => isHoveringFilterMenu = true}
onmouseleave={() => isHoveringFilterMenu = false}
>
{#if offlineStatus.isOffline}
<button class="dropdown-item" class:selected={filter === 'all' || filter === 'offline_all'} onclick={() => { filter = 'offline_all'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'all' || filter === 'offline_all'} onclick={() => { filter = 'offline_all'; closeFilterMenu(); }}>
{$t('offline.available')}
</button>
<button class="dropdown-item" class:selected={filter === 'offline_partial'} onclick={() => { filter = 'offline_partial'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'offline_partial'} onclick={() => { filter = 'offline_partial'; closeFilterMenu(); }}>
{$t('offline.partiallyAvailable')}
</button>
<button class="dropdown-item" class:selected={filter === 'offline_unavailable'} onclick={() => { filter = 'offline_unavailable'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'offline_unavailable'} onclick={() => { filter = 'offline_unavailable'; closeFilterMenu(); }}>
{$t('offline.notAvailableOffline')}
</button>
<div class="dropdown-divider"></div>
{/if}
<button class="dropdown-item" class:selected={filter === 'all' && !offlineStatus.isOffline} onclick={() => { filter = 'all'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'all' && !offlineStatus.isOffline} onclick={() => { filter = 'all'; closeFilterMenu(); }}>
{offlineStatus.isOffline ? $t('filter.all') : 'All'}
</button>
<button class="dropdown-item" class:selected={filter === 'visible'} onclick={() => { filter = 'visible'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'visible'} onclick={() => { filter = 'visible'; closeFilterMenu(); }}>
Visible
</button>
<button class="dropdown-item" class:selected={filter === 'hidden'} onclick={() => { filter = 'hidden'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'hidden'} onclick={() => { filter = 'hidden'; closeFilterMenu(); }}>
Hidden
</button>
</div>
@@ -744,27 +849,40 @@
<!-- Sort dropdown -->
<div class="dropdown-container">
<button class="control-btn" onclick={() => { showSortMenu = !showSortMenu; showFilterMenu = false; }}>
<button class="control-btn" onclick={() => {
if (showSortMenu) {
showSortMenu = false;
closeGlobalMenu(PM_SORT_MENU_ID);
} else {
showFilterMenu = false;
openGlobalMenu(PM_SORT_MENU_ID);
showSortMenu = true;
}
}}>
<ArrowUpDown size={16} />
<span>
{sort === 'name' ? 'Name' : sort === 'recent' ? 'Recent' : sort === 'playcount' ? 'Play Count' : sort === 'tracks' ? 'Track Count' : 'Custom'}
</span>
</button>
{#if showSortMenu}
<div class="dropdown-menu">
<button class="dropdown-item" class:selected={sort === 'name'} onclick={() => { sort = 'name'; showSortMenu = false; }}>
<div
class="dropdown-menu"
onmouseenter={() => isHoveringSortMenu = true}
onmouseleave={() => isHoveringSortMenu = false}
>
<button class="dropdown-item" class:selected={sort === 'name'} onclick={() => { sort = 'name'; closeSortMenu(); }}>
Name (A-Z)
</button>
<button class="dropdown-item" class:selected={sort === 'recent'} onclick={() => { sort = 'recent'; showSortMenu = false; }}>
<button class="dropdown-item" class:selected={sort === 'recent'} onclick={() => { sort = 'recent'; closeSortMenu(); }}>
Recent
</button>
<button class="dropdown-item" class:selected={sort === 'playcount'} onclick={() => { sort = 'playcount'; showSortMenu = false; }}>
<button class="dropdown-item" class:selected={sort === 'playcount'} onclick={() => { sort = 'playcount'; closeSortMenu(); }}>
Play Count
</button>
<button class="dropdown-item" class:selected={sort === 'tracks'} onclick={() => { sort = 'tracks'; showSortMenu = false; }}>
<button class="dropdown-item" class:selected={sort === 'tracks'} onclick={() => { sort = 'tracks'; closeSortMenu(); }}>
Track Count
</button>
<button class="dropdown-item" class:selected={sort === 'custom'} onclick={() => { sort = 'custom'; showSortMenu = false; }}>
<button class="dropdown-item" class:selected={sort === 'custom'} onclick={() => { sort = 'custom'; closeSortMenu(); }}>
Custom Order
</button>
</div>
+31
View File
@@ -1,9 +1,16 @@
import { openMenu as openGlobalMenu, closeMenu as closeGlobalMenu, subscribe as subscribeGlobal, getActiveMenuId } from './floatingMenuStore';
type Listener = (menuId: number | null) => void;
let activeMenuId: number | null = null;
const listeners = new Set<Listener>();
let nextMenuId = 1;
// Generate global menu ID for track menus
function getGlobalMenuId(localId: number): string {
return `track-menu-${localId}`;
}
export function allocateTrackMenuId(): number {
// Monotonic id to ensure only one TrackMenu instance matches the active id at a time.
return nextMenuId++;
@@ -15,7 +22,19 @@ export function getActiveTrackMenuId(): number | null {
export function setActiveTrackMenuId(menuId: number | null): void {
if (activeMenuId === menuId) return;
// Close previous in global store
if (activeMenuId !== null) {
closeGlobalMenu(getGlobalMenuId(activeMenuId));
}
activeMenuId = menuId;
// Open new in global store
if (menuId !== null) {
openGlobalMenu(getGlobalMenuId(menuId));
}
for (const listener of listeners) listener(activeMenuId);
}
@@ -26,3 +45,15 @@ export function subscribeActiveTrackMenuId(listener: Listener): () => void {
listeners.delete(listener);
};
}
// Subscribe to global store to close track menus when other menus open
subscribeGlobal(() => {
const globalActive = getActiveMenuId();
// If global active menu is not a track menu, close any open track menu
if (globalActive !== null && !globalActive.startsWith('track-menu-')) {
if (activeMenuId !== null) {
activeMenuId = null;
for (const listener of listeners) listener(null);
}
}
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Global Floating Menu Store
*
* Ensures only one floating menu can be open at a time across the entire app.
* All floating menus (context menus, dropdowns, popovers) should use this store.
*/
type Subscriber = () => void;
let activeMenuId: string | null = null;
const subscribers = new Set<Subscriber>();
function notify() {
subscribers.forEach(fn => fn());
}
/**
* Subscribe to store changes
*/
export function subscribe(fn: Subscriber): () => void {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
/**
* Get the currently active menu ID
*/
export function getActiveMenuId(): string | null {
return activeMenuId;
}
/**
* Check if a specific menu is currently active
*/
export function isMenuActive(menuId: string): boolean {
return activeMenuId === menuId;
}
/**
* Open a menu (closes any other open menu first)
*/
export function openMenu(menuId: string): void {
if (activeMenuId !== menuId) {
activeMenuId = menuId;
notify();
}
}
/**
* Close a specific menu (only if it's the active one)
*/
export function closeMenu(menuId: string): void {
if (activeMenuId === menuId) {
activeMenuId = null;
notify();
}
}
/**
* Close any open menu
*/
export function closeAll(): void {
if (activeMenuId !== null) {
activeMenuId = null;
notify();
}
}
/**
* Standard inactivity timeout for floating menus (in milliseconds)
*/
export const MENU_INACTIVITY_TIMEOUT = 2000;