Add searchable playlist dropdown and fix button text contrast
PlaylistModal changes: - Replace native select with custom searchable dropdown - Show max 8 playlists at a time with scroll indicator - Add search/autocomplete functionality - Support keyboard navigation (arrows, enter, escape) Button contrast fix: - Add --btn-primary-text CSS variable per theme - Use dark text on light accent colors (Warm, Nord, Dracula, etc.) - Use light text on dark accent colors (Adwaita, default)
This commit is contained in:
1 parent
561f7487d0
commit
c190fe79b0
2 files changed
+272
-13
No files matched your search
+14
-1
@@ -15,6 +15,7 @@
|
||||
--accent-primary: #4285F4;
|
||||
--accent-hover: #5a9bf4;
|
||||
--accent-active: #3275e4;
|
||||
--btn-primary-text: #ffffff;
|
||||
|
||||
--danger: #ef4444;
|
||||
--danger-bg: rgba(239, 68, 68, 0.1);
|
||||
@@ -82,6 +83,8 @@
|
||||
--text-muted: #666666;
|
||||
--text-disabled: #999999;
|
||||
|
||||
--btn-primary-text: #ffffff;
|
||||
|
||||
--border-subtle: #e0e0e0;
|
||||
--border-strong: #cccccc;
|
||||
|
||||
@@ -139,6 +142,7 @@
|
||||
--accent-primary: #d4a574;
|
||||
--accent-hover: #e0b888;
|
||||
--accent-active: #c49460;
|
||||
--btn-primary-text: #1a1814;
|
||||
|
||||
--border-subtle: #2e2820;
|
||||
--border-strong: #3d362a;
|
||||
@@ -159,6 +163,7 @@
|
||||
--accent-primary: #88c0d0;
|
||||
--accent-hover: #81a1c1;
|
||||
--accent-active: #5e81ac;
|
||||
--btn-primary-text: #2e3440;
|
||||
|
||||
--border-subtle: #434c5e;
|
||||
--border-strong: #4c566a;
|
||||
@@ -179,6 +184,7 @@
|
||||
--accent-primary: #bd93f9;
|
||||
--accent-hover: #ff79c6;
|
||||
--accent-active: #8be9fd;
|
||||
--btn-primary-text: #282a36;
|
||||
|
||||
--border-subtle: #44475a;
|
||||
--border-strong: #6272a4;
|
||||
@@ -199,6 +205,7 @@
|
||||
--accent-primary: #7aa2f7;
|
||||
--accent-hover: #bb9af7;
|
||||
--accent-active: #2ac3de;
|
||||
--btn-primary-text: #1a1b26;
|
||||
|
||||
--border-subtle: #292e42;
|
||||
--border-strong: #414868;
|
||||
@@ -219,6 +226,7 @@
|
||||
--accent-primary: #89b4fa;
|
||||
--accent-hover: #74c7ec;
|
||||
--accent-active: #b4befe;
|
||||
--btn-primary-text: #11111b;
|
||||
|
||||
--border-subtle: #1e1e2e;
|
||||
--border-strong: #313244;
|
||||
@@ -239,6 +247,7 @@
|
||||
--accent-primary: #907aa9;
|
||||
--accent-hover: #286983;
|
||||
--accent-active: #d7827e;
|
||||
--btn-primary-text: #faf4ed;
|
||||
|
||||
--border-subtle: #f2e9e1;
|
||||
--border-strong: #dfdad9;
|
||||
@@ -259,6 +268,7 @@
|
||||
--accent-primary: #3daee9;
|
||||
--accent-hover: #9b59b6;
|
||||
--accent-active: #1d99f3;
|
||||
--btn-primary-text: #141618;
|
||||
|
||||
--border-subtle: #292c30;
|
||||
--border-strong: #31363b;
|
||||
@@ -279,6 +289,7 @@
|
||||
--accent-primary: #3daee9;
|
||||
--accent-hover: #9b59b6;
|
||||
--accent-active: #2980b9;
|
||||
--btn-primary-text: #ffffff;
|
||||
|
||||
--border-subtle: #dee0e2;
|
||||
--border-strong: #c8cdd3;
|
||||
@@ -299,6 +310,7 @@
|
||||
--accent-primary: #3584e4;
|
||||
--accent-hover: #1c71d8;
|
||||
--accent-active: #1a5fb4;
|
||||
--btn-primary-text: #ffffff;
|
||||
|
||||
--border-subtle: #28282c;
|
||||
--border-strong: #2e2e32;
|
||||
@@ -319,6 +331,7 @@
|
||||
--accent-primary: #3584e4;
|
||||
--accent-hover: #1c71d8;
|
||||
--accent-active: #1a5fb4;
|
||||
--btn-primary-text: #ffffff;
|
||||
|
||||
--border-subtle: #f3f3f5;
|
||||
--border-strong: #deddda;
|
||||
@@ -494,7 +507,7 @@ select {
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: var(--text-primary);
|
||||
color: var(--btn-primary-text);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
|
||||
@@ -55,6 +55,13 @@
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Searchable playlist dropdown state
|
||||
let playlistSearchQuery = $state('');
|
||||
let isPlaylistDropdownOpen = $state(false);
|
||||
let highlightedIndex = $state(-1);
|
||||
let dropdownRef = $state<HTMLDivElement | null>(null);
|
||||
let inputRef = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Folders state
|
||||
let folders = $state<PlaylistFolder[]>([]);
|
||||
|
||||
@@ -88,6 +95,85 @@
|
||||
return pl.tracks_count + localCount;
|
||||
}
|
||||
|
||||
// Filtered playlists based on search query
|
||||
const filteredPlaylists = $derived(
|
||||
playlistSearchQuery.trim()
|
||||
? userPlaylists.filter(pl =>
|
||||
pl.name.toLowerCase().includes(playlistSearchQuery.toLowerCase())
|
||||
)
|
||||
: userPlaylists
|
||||
);
|
||||
|
||||
// Get display text for selected playlist
|
||||
const selectedPlaylistDisplay = $derived(() => {
|
||||
if (selectedPlaylistId === null) return '';
|
||||
if (selectedPlaylistId === CREATE_NEW_PLAYLIST) return '+ Create new playlist';
|
||||
const pl = userPlaylists.find(p => p.id === selectedPlaylistId);
|
||||
return pl ? `${pl.name} (${getTotalTrackCount(pl)} tracks)` : '';
|
||||
});
|
||||
|
||||
// Handle dropdown item click
|
||||
function selectPlaylist(id: number | null) {
|
||||
selectedPlaylistId = id;
|
||||
playlistSearchQuery = '';
|
||||
isPlaylistDropdownOpen = false;
|
||||
highlightedIndex = -1;
|
||||
}
|
||||
|
||||
// Handle dropdown keyboard navigation
|
||||
function handleDropdownKeydown(e: KeyboardEvent) {
|
||||
if (!isPlaylistDropdownOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
isPlaylistDropdownOpen = true;
|
||||
highlightedIndex = 0;
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const totalItems = filteredPlaylists.length + 1; // +1 for "Create new" option
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
highlightedIndex = (highlightedIndex + 1) % totalItems;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
highlightedIndex = highlightedIndex <= 0 ? totalItems - 1 : highlightedIndex - 1;
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (highlightedIndex === 0) {
|
||||
selectPlaylist(CREATE_NEW_PLAYLIST);
|
||||
} else if (highlightedIndex > 0 && highlightedIndex <= filteredPlaylists.length) {
|
||||
selectPlaylist(filteredPlaylists[highlightedIndex - 1].id);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
isPlaylistDropdownOpen = false;
|
||||
highlightedIndex = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
||||
isPlaylistDropdownOpen = false;
|
||||
highlightedIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Register click outside listener when dropdown opens
|
||||
$effect(() => {
|
||||
if (isPlaylistDropdownOpen) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to offline state changes
|
||||
$effect(() => {
|
||||
const unsubscribe = subscribeOffline(() => {
|
||||
@@ -128,6 +214,9 @@
|
||||
} else if (mode === 'addTrack') {
|
||||
selectedPlaylistId = null;
|
||||
folderId = null;
|
||||
playlistSearchQuery = '';
|
||||
isPlaylistDropdownOpen = false;
|
||||
highlightedIndex = -1;
|
||||
loadLocalTrackCounts();
|
||||
}
|
||||
}
|
||||
@@ -452,18 +541,61 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="playlist-select">Choose playlist</label>
|
||||
<select
|
||||
id="playlist-select"
|
||||
bind:value={selectedPlaylistId}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value={null}>Select a playlist...</option>
|
||||
<option value={CREATE_NEW_PLAYLIST}>+ Create new playlist</option>
|
||||
{#each userPlaylists as pl (pl.id)}
|
||||
<option value={pl.id}>{pl.name} ({getTotalTrackCount(pl)} tracks)</option>
|
||||
{/each}
|
||||
</select>
|
||||
<label for="playlist-search">Choose playlist</label>
|
||||
<div class="playlist-dropdown" bind:this={dropdownRef}>
|
||||
<input
|
||||
type="text"
|
||||
id="playlist-search"
|
||||
bind:this={inputRef}
|
||||
bind:value={playlistSearchQuery}
|
||||
placeholder={selectedPlaylistId ? selectedPlaylistDisplay() : 'Search playlists...'}
|
||||
disabled={loading}
|
||||
onfocus={() => isPlaylistDropdownOpen = true}
|
||||
onkeydown={handleDropdownKeydown}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if selectedPlaylistId !== null && !isPlaylistDropdownOpen}
|
||||
<button
|
||||
type="button"
|
||||
class="clear-selection"
|
||||
onclick={() => selectPlaylist(null)}
|
||||
disabled={loading}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isPlaylistDropdownOpen}
|
||||
<div class="playlist-dropdown-list">
|
||||
<button
|
||||
type="button"
|
||||
class="playlist-dropdown-item create-new"
|
||||
class:highlighted={highlightedIndex === 0}
|
||||
onclick={() => selectPlaylist(CREATE_NEW_PLAYLIST)}
|
||||
>
|
||||
+ Create new playlist
|
||||
</button>
|
||||
{#each filteredPlaylists.slice(0, 8) as pl, i (pl.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="playlist-dropdown-item"
|
||||
class:highlighted={highlightedIndex === i + 1}
|
||||
onclick={() => selectPlaylist(pl.id)}
|
||||
>
|
||||
<span class="playlist-name">{pl.name}</span>
|
||||
<span class="playlist-count">{getTotalTrackCount(pl)} tracks</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredPlaylists.length === 0 && playlistSearchQuery.trim()}
|
||||
<div class="no-results">No playlists found</div>
|
||||
{/if}
|
||||
{#if filteredPlaylists.length > 8}
|
||||
<div class="more-results">
|
||||
{filteredPlaylists.length - 8} more playlist{filteredPlaylists.length - 8 !== 1 ? 's' : ''}...
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedPlaylistId === CREATE_NEW_PLAYLIST}
|
||||
@@ -715,6 +847,120 @@
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Searchable playlist dropdown */
|
||||
.playlist-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.playlist-dropdown input {
|
||||
width: 100%;
|
||||
padding: 10px 36px 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.playlist-dropdown input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.playlist-dropdown input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.clear-selection {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.clear-selection:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.playlist-dropdown-list {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.playlist-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.playlist-dropdown-item:hover,
|
||||
.playlist-dropdown-item.highlighted {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.playlist-dropdown-item.create-new {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.playlist-dropdown-item .playlist-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.playlist-dropdown-item .playlist-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.no-results,
|
||||
.more-results {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.more-results {
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
|
||||
Reference in new issue
Block a user